mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/create-pr-workspace-ui
# Conflicts: # src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx
This commit is contained in:
@@ -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<ConnectionState, string> = {
|
||||
'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<typeof setTimeout> | 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)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
@@ -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<Map<string, TerminalModes>>(new Map())
|
||||
const terminalGestureInputBucketsRef = useRef<Map<string, TerminalGestureInputBucket>>(new Map())
|
||||
const terminalGestureInputQueuesRef = useRef<Map<string, TerminalGestureInputQueue>>(new Map())
|
||||
const terminalGestureInputInFlightRef = useRef<Set<string>>(new Set())
|
||||
const initialModesSeenRef = useRef<Set<string>>(new Set())
|
||||
const deviceTokenRef = useRef<string | null>(null)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const connStateRef = useRef<ConnectionState>(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 && (
|
||||
|
||||
@@ -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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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<TerminalWebViewHandle, Props>(function
|
||||
onSelectionEvicted,
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic
|
||||
onHaptic,
|
||||
onTerminalInput
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@@ -1395,10 +1653,23 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(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<TerminalWebViewHandle, Props>(function
|
||||
onSelectionEvicted,
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic
|
||||
onHaptic,
|
||||
onTerminalInput
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ExternalAutomationJob } from '../../shared/automations-types'
|
||||
|
||||
type ExternalJobRecord = Record<string, unknown>
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value : null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
async function isCommandOnPath(command: string): Promise<boolean> {
|
||||
const finder = process.platform === 'win32' ? 'where' : 'which'
|
||||
try {
|
||||
await execFileAsync(finder, [command], { encoding: 'utf-8' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function readLocalHermesJobs(): Promise<unknown[]> {
|
||||
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<unknown[]> {
|
||||
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<ExternalAutomationManager | null> {
|
||||
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<ExternalAutomationManager | null> {
|
||||
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<ExternalAutomationManager> {
|
||||
return listRemoteManager(target, 'hermes')
|
||||
}
|
||||
|
||||
async function listRemoteOpenClawManager(target: SshTarget): Promise<ExternalAutomationManager> {
|
||||
return listRemoteManager(target, 'openclaw')
|
||||
}
|
||||
|
||||
async function listRemoteManager(
|
||||
target: SshTarget,
|
||||
provider: ExternalAutomationProvider
|
||||
): Promise<ExternalAutomationManager> {
|
||||
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<ExternalAutomationManager[]> {
|
||||
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<void> {
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<typeof Os>('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<typeof Os>('os')),
|
||||
homedir: () => root
|
||||
}))
|
||||
vi.doMock('fs/promises', async () => ({
|
||||
...(await vi.importActual<typeof FsPromises>('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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, ClaudeUsageWorktreeRef>,
|
||||
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<string, ClaudeUsageWorktreeRef>
|
||||
): 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<string, ClaudeUsageWorktreeRef>
|
||||
): 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<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
@@ -89,11 +145,17 @@ async function walkJsonlFiles(dirPath: string): Promise<string[]> {
|
||||
}
|
||||
|
||||
export async function listClaudeTranscriptFiles(): Promise<string[]> {
|
||||
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<ClaudeUsageProcessedFile> {
|
||||
@@ -109,11 +171,69 @@ export async function getProcessedFileInfo(filePath: string): Promise<ClaudeUsag
|
||||
return {
|
||||
path: filePath,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
size: fileStat.size,
|
||||
lineCount
|
||||
}
|
||||
}
|
||||
|
||||
export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null {
|
||||
async function getProcessedFileStat(
|
||||
filePath: string
|
||||
): Promise<Omit<ClaudeUsageProcessedFile, 'lineCount'>> {
|
||||
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<string, number>()
|
||||
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<ClaudeUsageParsedTurn[]> {
|
||||
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<string, ClaudeUsageWorktreeRef>
|
||||
): Promise<ClaudeUsageAttributedTurn[]> {
|
||||
const attributed: ClaudeUsageAttributedTurn[] = []
|
||||
const canonicalCwdByPath = new Map<string, string>()
|
||||
|
||||
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<string, ClaudeUsageSession>,
|
||||
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<string, ClaudeUsageDailyAggregate>,
|
||||
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<string, ClaudeUsageSession>
|
||||
): 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<string, ClaudeUsageWorktreeRef>
|
||||
): Promise<ClaudeUsagePersistedFile> {
|
||||
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<string, ClaudeUsageSession>()
|
||||
const dailyByKey = new Map<string, ClaudeUsageDailyAggregate>()
|
||||
|
||||
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<string, { path: string; worktreeId: string; displayName: string }[]>
|
||||
|
||||
@@ -17,6 +17,7 @@ function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): 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)
|
||||
})
|
||||
})
|
||||
|
||||
+146
-18
@@ -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<ClaudeModelPricing>
|
||||
|
||||
const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
|
||||
'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<string, string> = {
|
||||
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, UsageWorktreeRef[]>): 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<string> {
|
||||
const repos = this.store.getRepos()
|
||||
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
|
||||
return getWorktreeFingerprint(worktreesByRepo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+119
-10
@@ -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<string, unknown>
|
||||
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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
+136
-14
@@ -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<string, { input: number; cachedInput: number; output: number }> = {
|
||||
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<string, CodexModelPricing> = {
|
||||
'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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<TerminalModes['mouseTrackingMode']>
|
||||
|
||||
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<void>((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('')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<ExternalAutomationManager[]> => {
|
||||
return listExternalAutomationManagers(store)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'automations:runExternalAction',
|
||||
(_event, input: ExternalAutomationActionInput) => {
|
||||
return runExternalAutomationAction(input)
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'automations:create',
|
||||
(_event, input: AutomationCreateInput): Automation => store.createAutomation(input)
|
||||
|
||||
@@ -224,6 +224,8 @@ import type {
|
||||
AutomationCreateInput,
|
||||
AutomationDispatchRequest,
|
||||
AutomationDispatchResult,
|
||||
ExternalAutomationActionInput,
|
||||
ExternalAutomationManager,
|
||||
AutomationRun,
|
||||
AutomationUpdateInput
|
||||
} from '../shared/automations-types'
|
||||
@@ -1610,6 +1612,8 @@ export type PreloadApi = {
|
||||
automations: {
|
||||
list: () => Promise<Automation[]>
|
||||
listRuns: (args?: { automationId?: string }) => Promise<AutomationRun[]>
|
||||
listExternalManagers: () => Promise<ExternalAutomationManager[]>
|
||||
runExternalAction: (input: ExternalAutomationActionInput) => Promise<void>
|
||||
create: (input: AutomationCreateInput) => Promise<Automation>
|
||||
update: (args: { id: string; updates: AutomationUpdateInput }) => Promise<Automation>
|
||||
delete: (args: { id: string }) => Promise<void>
|
||||
|
||||
@@ -100,6 +100,8 @@ import type {
|
||||
AutomationCreateInput,
|
||||
AutomationDispatchRequest,
|
||||
AutomationDispatchResult,
|
||||
ExternalAutomationActionInput,
|
||||
ExternalAutomationManager,
|
||||
AutomationRun,
|
||||
AutomationUpdateInput
|
||||
} from '../shared/automations-types'
|
||||
@@ -2644,6 +2646,10 @@ const api = {
|
||||
list: (): Promise<Automation[]> => ipcRenderer.invoke('automations:list'),
|
||||
listRuns: (args?: { automationId?: string }): Promise<AutomationRun[]> =>
|
||||
ipcRenderer.invoke('automations:listRuns', args),
|
||||
listExternalManagers: (): Promise<ExternalAutomationManager[]> =>
|
||||
ipcRenderer.invoke('automations:listExternalManagers'),
|
||||
runExternalAction: (input: ExternalAutomationActionInput): Promise<void> =>
|
||||
ipcRenderer.invoke('automations:runExternalAction', input),
|
||||
create: (input: AutomationCreateInput): Promise<Automation> =>
|
||||
ipcRenderer.invoke('automations:create', input),
|
||||
update: (args: { id: string; updates: AutomationUpdateInput }): Promise<Automation> =>
|
||||
|
||||
@@ -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<boolean> {
|
||||
try {
|
||||
await execFileAsync('/bin/sh', ['-lc', `which ${command}`], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async readJobs(provider: ExternalProvider): Promise<unknown[]> {
|
||||
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<string, unknown>): 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<string, unknown> = {}): 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 }
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
void _gitHandler
|
||||
|
||||
const _preflightHandler = new PreflightHandler(dispatcher)
|
||||
const _externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher)
|
||||
void _preflightHandler
|
||||
void _externalAutomationsHandler
|
||||
|
||||
const _portScanHandler = new PortScanHandler(dispatcher)
|
||||
void _portScanHandler
|
||||
|
||||
@@ -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<Automation[]>([])
|
||||
const [runs, setRuns] = useState<AutomationRun[]>([])
|
||||
const [externalManagers, setExternalManagers] = useState<ExternalAutomationManager[]>([])
|
||||
const [externalActionKey, setExternalActionKey] = useState<string | null>(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<AutomationDraft | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null)
|
||||
const [externalDeleteTarget, setExternalDeleteTarget] = useState<{
|
||||
manager: ExternalAutomationManager
|
||||
job: ExternalAutomationJob
|
||||
} | null>(null)
|
||||
const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false)
|
||||
const editRequestRef = useRef(0)
|
||||
const deleteConfirmButtonRef = useRef<HTMLButtonElement>(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<void> => {
|
||||
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<void> => {
|
||||
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 {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={externalDeleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setExternalDeleteTarget(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
deleteConfirmButtonRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Delete External Automation</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Delete{' '}
|
||||
<span className="break-all font-medium text-foreground">
|
||||
{externalDeleteTarget?.job.name}
|
||||
</span>{' '}
|
||||
from {externalDeleteTarget?.manager.label}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{externalDeleteTarget ? (
|
||||
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
|
||||
<div className="break-all font-medium text-foreground">
|
||||
{externalDeleteTarget.job.name}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">{externalDeleteTarget.job.schedule}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setExternalDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
ref={deleteConfirmButtonRef}
|
||||
variant="destructive"
|
||||
onClick={() => void confirmDeleteExternalAutomation()}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(280px,360px)_1fr] overflow-hidden border-t border-border/50">
|
||||
<section className="flex min-h-0 flex-col border-r border-border/50 bg-muted/20">
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
@@ -716,6 +830,12 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
onToggle={(automation) => void toggleAutomation(automation)}
|
||||
onDelete={requestDeleteAutomation}
|
||||
/>
|
||||
<ExternalAutomationManagers
|
||||
managers={externalManagers}
|
||||
now={relativeNow}
|
||||
runningActionKey={externalActionKey}
|
||||
onAction={requestExternalAction}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={label}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExternalAutomationManagers({
|
||||
managers,
|
||||
now,
|
||||
runningActionKey,
|
||||
onAction
|
||||
}: ExternalAutomationManagersProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="mt-6 rounded-md border border-border/50 bg-muted/20 shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">External managers</div>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{managers.reduce((sum, manager) => sum + manager.jobs.length, 0)} jobs
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{managers.map((manager) => (
|
||||
<div key={manager.id} className="px-3 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{manager.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{manager.status === 'available'
|
||||
? manager.canManage
|
||||
? 'Manageable'
|
||||
: 'Read-only'
|
||||
: 'Unavailable'}
|
||||
{manager.error ? ` - ${manager.error}` : null}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={manager.status === 'available' ? 'secondary' : 'outline'}>
|
||||
{manager.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40">
|
||||
{manager.jobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="grid grid-cols-[minmax(0,1fr)_minmax(8rem,auto)_auto] items-center gap-3 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{job.name}</span>
|
||||
<Badge variant={job.enabled ? 'secondary' : 'outline'}>
|
||||
{job.enabled ? 'Active' : 'Paused'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{job.schedule} · next {formatExternalDate(job.nextRunAt, now)}
|
||||
</div>
|
||||
{job.promptPreview || job.lastError ? (
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{job.lastError ?? job.promptPreview}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-xs text-muted-foreground md:block">
|
||||
Last {formatExternalDate(job.lastRunAt, now)}
|
||||
{job.lastStatus ? ` · ${job.lastStatus}` : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ExternalActionButton
|
||||
label="Run external automation"
|
||||
disabled={!manager.canManage || runningActionKey !== null}
|
||||
onClick={() => onAction(manager, job, 'run')}
|
||||
>
|
||||
{runningActionKey === actionKey(manager, job, 'run') ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
</ExternalActionButton>
|
||||
<ExternalActionButton
|
||||
label={
|
||||
job.enabled ? 'Pause external automation' : 'Resume external automation'
|
||||
}
|
||||
disabled={!manager.canManage || runningActionKey !== null}
|
||||
onClick={() => onAction(manager, job, job.enabled ? 'pause' : 'resume')}
|
||||
>
|
||||
{runningActionKey ===
|
||||
actionKey(manager, job, job.enabled ? 'pause' : 'resume') ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : job.enabled ? (
|
||||
<Pause className="size-3.5" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
</ExternalActionButton>
|
||||
<ExternalActionButton
|
||||
label="Delete external automation"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={!manager.canManage || runningActionKey !== null}
|
||||
onClick={() => onAction(manager, job, 'delete')}
|
||||
>
|
||||
{runningActionKey === actionKey(manager, job, 'delete') ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
</ExternalActionButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{manager.jobs.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No {manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw'} jobs found.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{managers.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No external automation managers found.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
'qwen',
|
||||
'rovo',
|
||||
'hermes',
|
||||
'openclaw',
|
||||
'copilot',
|
||||
'github',
|
||||
'github copilot',
|
||||
|
||||
@@ -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<string>
|
||||
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<HTMLDivElement>(null)
|
||||
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(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<string>(),
|
||||
repoOrder,
|
||||
workspaceStatuses
|
||||
workspaceStatuses,
|
||||
repoGroupOrdering
|
||||
).filter((r): r is Extract<Row, { type: 'item' }> => 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 ? (
|
||||
<div
|
||||
role="presentation"
|
||||
className="pointer-events-none absolute left-2 right-2 z-10 border-t border-dashed border-muted-foreground/70"
|
||||
@@ -525,6 +533,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const isRepoHeader = groupBy === 'repo' && row.repo !== undefined
|
||||
const repoIdForHeader = isRepoHeader ? row.repo!.id : undefined
|
||||
const isDraggingThis =
|
||||
canReorderRepoHeaders &&
|
||||
repoDrag.state.draggingRepoId !== null &&
|
||||
repoDrag.state.draggingRepoId === repoIdForHeader
|
||||
const headerWorkspaceStatus =
|
||||
@@ -592,7 +601,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
{row.icon ? (
|
||||
<div
|
||||
onPointerDown={
|
||||
isRepoHeader && repoIdForHeader
|
||||
canReorderRepoHeaders && isRepoHeader && repoIdForHeader
|
||||
? (e) => 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<string, number>()
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,8 @@ 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<string, TerminalTab[]>
|
||||
browserTabsByWorktree: Record<string, { id: string }[]>
|
||||
@@ -72,7 +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', '00000000-0000-4000-8000-000000000001')
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
mockState = {
|
||||
...mockState,
|
||||
tabsByWorktree: {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, unknown> | null,
|
||||
collapsedGroups: Set<string>,
|
||||
repoOrder?: Map<string, number>,
|
||||
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)
|
||||
|
||||
@@ -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) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
@@ -123,7 +124,7 @@ export function StatsPane(): React.JSX.Element {
|
||||
: 'text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab === 'claude' ? 'Claude' : 'Codex'}
|
||||
{tab === 'overview' ? 'Overview' : tab === 'claude' ? 'Claude' : 'Codex'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -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. */}
|
||||
<div>{activeUsageTab === 'claude' ? <ClaudeUsagePane /> : <CodexUsagePane />}</div>
|
||||
<div>
|
||||
{activeUsageTab === 'overview' ? (
|
||||
<UsageOverviewPane />
|
||||
) : activeUsageTab === 'claude' ? (
|
||||
<ClaudeUsagePane />
|
||||
) : (
|
||||
<CodexUsagePane />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<UsageOverviewDailyPoint['intensity'], string> = {
|
||||
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 (
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">Token mix</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Combined input, output, and cache tokens across enabled providers.
|
||||
</p>
|
||||
</div>
|
||||
{overview.reasoningTokens > 0 ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{formatUsageTokens(overview.reasoningTokens)} reasoning
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mixTotal > 0 ? (
|
||||
<div
|
||||
className="flex h-3 overflow-hidden rounded-full border border-border/60 bg-muted"
|
||||
aria-label="Combined token mix"
|
||||
>
|
||||
{segments.map((segment) =>
|
||||
segment.value > 0 ? (
|
||||
<div
|
||||
key={segment.key}
|
||||
className={segment.className}
|
||||
style={{ width: `${(segment.value / mixTotal) * 100}%` }}
|
||||
aria-label={`${segment.label}: ${segment.value.toLocaleString()} tokens`}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3 rounded-full border border-dashed border-border/60 bg-muted/40" />
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
{segments.map((segment) => (
|
||||
<div key={segment.key} className="flex min-w-0 items-center gap-2">
|
||||
<span className={`size-2 shrink-0 rounded-full ${segment.className}`} />
|
||||
<span className="min-w-0 truncate">
|
||||
{segment.label}: {formatUsageTokens(segment.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DailyIntensityGrid({
|
||||
days,
|
||||
bestDay
|
||||
}: {
|
||||
days: UsageOverviewDailyPoint[]
|
||||
bestDay: UsageOverviewDailyPoint | null
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">Daily intensity</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recent combined Claude and Codex token activity.
|
||||
</p>
|
||||
</div>
|
||||
{bestDay && bestDay.totalTokens > 0 ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
Best: {formatDayLabel(bestDay.day)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid grid-cols-[repeat(14,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(21,minmax(0,1fr))]"
|
||||
aria-label="Recent token activity heatmap"
|
||||
>
|
||||
{days.map((day) => (
|
||||
<div
|
||||
key={day.day}
|
||||
className={`aspect-square min-h-3 rounded-[2px] border ${INTENSITY_CLASS[day.intensity]}`}
|
||||
aria-label={`${day.day}: ${day.totalTokens.toLocaleString()} tokens`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>{formatDayLabel(days[0]?.day ?? '')}</span>
|
||||
<span>Less</span>
|
||||
<div className="flex items-center gap-1" aria-hidden>
|
||||
{[0, 1, 2, 3, 4].map((intensity) => (
|
||||
<span
|
||||
key={intensity}
|
||||
className={`size-2 rounded-[2px] border ${INTENSITY_CLASS[intensity as UsageOverviewDailyPoint['intensity']]}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>More</span>
|
||||
<span>{formatDayLabel(days.at(-1)?.day ?? '')}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h5 className="truncate text-sm font-semibold text-foreground">{provider.label}</h5>
|
||||
<Badge variant={statusVariant}>{status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{provider.topModel ?? 'No model yet'}
|
||||
{provider.topProject ? ` - ${provider.topProject}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{!provider.enabled ? (
|
||||
<Button variant="outline" size="xs" onClick={onEnable}>
|
||||
Enable
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
<span>{formatUsageTokens(provider.totalTokens)} tokens</span>
|
||||
<span>
|
||||
{provider.sessions.toLocaleString()} sessions - {provider.activityCount.toLocaleString()}{' '}
|
||||
{provider.activityLabel}
|
||||
</span>
|
||||
<span>{formatUsageCost(provider.estimatedCostUsd)}</span>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-foreground/75"
|
||||
style={{ width: `${Math.max(share * 100, provider.totalTokens > 0 ? 2 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{provider.lastScanError ? (
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="size-3" />
|
||||
{provider.lastScanError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4" data-testid="usage-overview-pane">
|
||||
<section className="rounded-lg border border-border/60 bg-card/30 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground">Usage Overview</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{formatUpdatedAt(overview.lastUpdatedAt)}
|
||||
{overview.hasPartialCost ? ' - some model prices are unavailable' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={handleRefresh}
|
||||
disabled={!overview.hasAnyEnabledProvider || isScanning}
|
||||
aria-label="Refresh usage overview"
|
||||
>
|
||||
<RefreshCw className={`size-3.5 ${isScanning ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Refresh
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!overview.hasAnyEnabledProvider ? (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5">
|
||||
<div className="max-w-xl space-y-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">Start tracking tokens</h4>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Enable a provider to scan local agent logs and build the combined token ledger.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => void enableClaudeUsage()}>
|
||||
Enable Claude
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => void enableCodexUsage()}>
|
||||
Enable Codex
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label="Total tokens"
|
||||
value={formatUsageTokens(overview.totalTokens)}
|
||||
icon={<Sparkles className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Est. cost"
|
||||
value={formatUsageCost(overview.estimatedCostUsd)}
|
||||
icon={<Coins className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active days"
|
||||
value={overview.activeDays.toLocaleString()}
|
||||
icon={<CalendarDays className="size-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cache share"
|
||||
value={formatPercent(overview.cacheShare)}
|
||||
icon={<DatabaseZap className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!overview.hasAnyData ? (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
|
||||
No local Claude or Codex usage found yet. The overview will populate after the next
|
||||
agent session writes token logs.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<DailyIntensityGrid days={recentDays} bestDay={overview.bestDay} />
|
||||
<TokenMixBar overview={overview} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">Providers</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{overview.enabledProviderCount} enabled - {overview.dataProviderCount} with data
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Activity className="size-3" />
|
||||
{overview.sessions.toLocaleString()} sessions
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{overview.providers.map((provider) => (
|
||||
<ProviderRow
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
totalTokens={overview.totalTokens}
|
||||
onEnable={() => {
|
||||
if (provider.id === 'claude') {
|
||||
void enableClaudeUsage()
|
||||
} else {
|
||||
void enableCodexUsage()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, Omit<UsageOverviewDailyPoint, 'intensity'>>()
|
||||
|
||||
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<UsageOverviewDailyPoint | null>(
|
||||
(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<number | null>(
|
||||
(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)}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -165,6 +165,7 @@ const ICONABLE_AGENT_TYPES: Record<TuiAgent, true> = {
|
||||
'qwen-code': true,
|
||||
rovo: true,
|
||||
hermes: true,
|
||||
openclaw: true,
|
||||
copilot: true
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -37,6 +37,7 @@ const TUI_AGENT_KIND_BY_AGENT = {
|
||||
'qwen-code': 'qwen-code',
|
||||
rovo: 'rovo',
|
||||
hermes: 'hermes',
|
||||
openclaw: 'openclaw',
|
||||
copilot: 'copilot'
|
||||
} satisfies Record<TuiAgent, ConcreteAgentKind>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export const AGENT_KIND_VALUES = [
|
||||
'qwen-code',
|
||||
'rovo',
|
||||
'hermes',
|
||||
'openclaw',
|
||||
'copilot',
|
||||
'other'
|
||||
] as const
|
||||
|
||||
@@ -211,6 +211,12 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
|
||||
expectedProcess: 'hermes',
|
||||
promptInjectionMode: 'stdin-after-start'
|
||||
},
|
||||
openclaw: {
|
||||
detectCmd: 'openclaw',
|
||||
launchCmd: 'openclaw',
|
||||
expectedProcess: 'openclaw',
|
||||
promptInjectionMode: 'stdin-after-start'
|
||||
},
|
||||
copilot: {
|
||||
detectCmd: 'copilot',
|
||||
launchCmd: 'copilot',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string>(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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user