diff --git a/apps/desktop/src/main/agent-history/index.ts b/apps/desktop/src/main/agent-history/index.ts index 82ef09e..19a1183 100644 --- a/apps/desktop/src/main/agent-history/index.ts +++ b/apps/desktop/src/main/agent-history/index.ts @@ -117,7 +117,7 @@ function isDirty( ): boolean { const stored = scanState[agentId]; if (!stored) return true; - const maxMtime = files.length > 0 ? Math.max(...files.map((f) => f.mtime)) : 0; + const maxMtime = files.reduce((m, f) => f.mtime > m ? f.mtime : m, 0); return files.length !== stored.fileCount || maxMtime !== stored.maxMtime; } @@ -159,7 +159,7 @@ function runRefresh(): Promise { const scanned = watcher.scan(); nativeByAgent.set(watcher.agentId, scanned); - const maxMtime = files.length > 0 ? Math.max(...files.map((f) => f.mtime)) : 0; + const maxMtime = files.reduce((m, f) => f.mtime > m ? f.mtime : m, 0); store.scanState[watcher.agentId] = { fileCount: files.length, maxMtime, diff --git a/apps/desktop/src/main/agent-history/scanners.ts b/apps/desktop/src/main/agent-history/scanners.ts index fa31ae4..55d036e 100644 --- a/apps/desktop/src/main/agent-history/scanners.ts +++ b/apps/desktop/src/main/agent-history/scanners.ts @@ -22,15 +22,39 @@ * session histories into memory. */ -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { execFileSync } from 'node:child_process'; +import { StringDecoder } from 'node:string_decoder'; import type { AgentHistoryEntry } from '@tday/shared'; /** Maximum lines scanned per file to extract the title. */ const MAX_TITLE_SCAN_LINES = 150; +/** Read at most MAX_TITLE_SCAN_LINES lines from a file without loading it fully. + * Reads up to 64 KB synchronously — sufficient for 150 JSONL lines in practice. + * Uses StringDecoder to correctly handle multi-byte UTF-8 sequences at the chunk boundary. + */ +function readFirstLines(filePath: string): string[] { + const CHUNK_BYTES = 65536; // 64 KB + let fd: number | null = null; + try { + fd = openSync(filePath, 'r'); + const buf = Buffer.alloc(CHUNK_BYTES); + const bytesRead = readSync(fd, buf, 0, CHUNK_BYTES, 0); + const decoder = new StringDecoder('utf8'); + // write() handles any incomplete multi-byte sequence at the boundary + const text = decoder.write(buf.slice(0, bytesRead)); + const lines = text.split('\n'); + return lines.length > MAX_TITLE_SCAN_LINES ? lines.slice(0, MAX_TITLE_SCAN_LINES) : lines; + } catch { + return []; + } finally { + if (fd !== null) { try { closeSync(fd); } catch { /* ignore */ } } + } +} + // ── Helpers ────────────────────────────────────────────────────────────────── function truncateTitle(s: string, max = 80): string { @@ -121,10 +145,9 @@ export function scanClaudeHistory(): AgentHistoryEntry[] { let messageCount = 0; try { - const content = readFileSync(filePath, 'utf8'); let lineIdx = 0; - for (const line of content.split('\n')) { - if (lineIdx++ > MAX_TITLE_SCAN_LINES) break; + for (const line of readFirstLines(filePath)) { + lineIdx++; const trimmed = line.trim(); if (!trimmed) continue; try { @@ -204,10 +227,9 @@ export function scanCodexHistory(): AgentHistoryEntry[] { let messageCount = 0; try { - const content = readFileSync(filePath, 'utf8'); let lineIdx = 0; - for (const line of content.split('\n')) { - if (lineIdx++ > MAX_TITLE_SCAN_LINES) break; + for (const line of readFirstLines(filePath)) { + lineIdx++; const trimmed = line.trim(); if (!trimmed) continue; try { @@ -381,10 +403,9 @@ export function scanGeminiHistory(): AgentHistoryEntry[] { let messageCount = 0; try { - const content = readFileSync(filePath, 'utf8'); let lineIdx = 0; - for (const line of content.split('\n')) { - if (lineIdx++ > MAX_TITLE_SCAN_LINES) break; + for (const line of readFirstLines(filePath)) { + lineIdx++; const trimmed = line.trim(); if (!trimmed) continue; try { @@ -492,10 +513,9 @@ export function scanPiHistory(): AgentHistoryEntry[] { let cwd = ''; try { - const content = readFileSync(filePath, 'utf8'); let lineIdx = 0; - for (const line of content.split('\n')) { - if (lineIdx++ > MAX_TITLE_SCAN_LINES) break; + for (const line of readFirstLines(filePath)) { + lineIdx++; const trimmed = line.trim(); if (!trimmed) continue; try { diff --git a/apps/desktop/src/main/agent-history/store.ts b/apps/desktop/src/main/agent-history/store.ts index e331cb4..a6b5aa9 100644 --- a/apps/desktop/src/main/agent-history/store.ts +++ b/apps/desktop/src/main/agent-history/store.ts @@ -75,7 +75,7 @@ export function loadStore(): HistoryStore { export function saveStore(store: HistoryStore): void { try { if (!existsSync(TDAY_DIR)) mkdirSync(TDAY_DIR, { recursive: true }); - writeFileSync(INDEX_TMP, JSON.stringify(store, null, 2), 'utf8'); + writeFileSync(INDEX_TMP, JSON.stringify(store), 'utf8'); renameSync(INDEX_TMP, INDEX_FILE); hotCache = store; } catch { diff --git a/apps/desktop/src/main/gateway/adapter.ts b/apps/desktop/src/main/gateway/adapter.ts index 57c1252..fdb7124 100644 --- a/apps/desktop/src/main/gateway/adapter.ts +++ b/apps/desktop/src/main/gateway/adapter.ts @@ -58,6 +58,16 @@ export function sessionKeyFromRequest(req: IncomingMessage): string { return ''; } +const MAX_CONVERSATION_ENTRIES = 500; + +/** Evict the oldest entry if the Map exceeds the given limit. */ +function evictOldest(map: Map, limit: number): void { + while (map.size > limit) { + const oldest = map.keys().next().value; + if (oldest !== undefined) map.delete(oldest); + } +} + // ─── Adapter ───────────────────────────────────────────────────────────────── export class CodexDeepSeekAnthropicAdapter implements GatewayAdapter { @@ -130,7 +140,11 @@ export class CodexDeepSeekAnthropicAdapter implements GatewayAdapter { const key = sessionKeyFromRequest(req); if (!key) return new ThinkingState(); let s = this.thinkingStates.get(key); - if (!s) { s = new ThinkingState(); this.thinkingStates.set(key, s); } + if (!s) { + s = new ThinkingState(); + this.thinkingStates.set(key, s); + evictOldest(this.thinkingStates, MAX_CONVERSATION_ENTRIES); + } return s; } @@ -211,6 +225,7 @@ export class CodexDeepSeekAnthropicAdapter implements GatewayAdapter { const json = await upstream.json() as AResponse; const { output, outputText } = convertAnthropicResponse(json, thinkingState, responseId); this.conversations.set(responseId, [...allMessages, ...buildStoredMessages(json.content)]); + evictOldest(this.conversations, MAX_CONVERSATION_ENTRIES); appendUsage({ ts: Date.now(), agentId, @@ -315,6 +330,7 @@ export class CodexDeepSeekAnthropicAdapter implements GatewayAdapter { responseId, [...allMessages, ...buildStoredMessagesFromStream(output, completedReasoningText)], ); + evictOldest(this.conversations, MAX_CONVERSATION_ENTRIES); appendUsage({ ts: Date.now(), agentId, diff --git a/apps/desktop/src/main/gateway/bridge/input.ts b/apps/desktop/src/main/gateway/bridge/input.ts index 59c0ac7..3781e68 100644 --- a/apps/desktop/src/main/gateway/bridge/input.ts +++ b/apps/desktop/src/main/gateway/bridge/input.ts @@ -234,8 +234,7 @@ export function resolveThinkingForAssistantText( */ export function stripReasoningContent(input: unknown): unknown { if (!Array.isArray(input)) return input; - const str = JSON.stringify(input); - if (!str.includes('reasoning_content')) return input; + if (!input.some((item) => item && typeof item === 'object' && 'reasoning_content' in (item as object))) return input; return input.map((item) => { if (!item || typeof item !== 'object') return item; const obj = item as Obj; diff --git a/apps/desktop/src/main/gateway/deepseek/state.ts b/apps/desktop/src/main/gateway/deepseek/state.ts index 9784503..561f12b 100644 --- a/apps/desktop/src/main/gateway/deepseek/state.ts +++ b/apps/desktop/src/main/gateway/deepseek/state.ts @@ -22,8 +22,10 @@ const DEFAULT_LIMIT = 1024; export class ThinkingState { private readonly records = new Map(); private readonly recordOrder: string[] = []; + private recordHead = 0; private readonly textRecords = new Map(); private readonly textOrder: string[] = []; + private textHead = 0; private readonly limit: number; constructor(limit = DEFAULT_LIMIT) { @@ -112,12 +114,12 @@ export class ThinkingState { // ─── Private ────────────────────────────────────────────────────────────── private prune(): void { - while (this.recordOrder.length > this.limit) { - const id = this.recordOrder.shift()!; + while (this.recordOrder.length - this.recordHead > this.limit) { + const id = this.recordOrder[this.recordHead++]; this.records.delete(id); } - while (this.textOrder.length > this.limit) { - const key = this.textOrder.shift()!; + while (this.textOrder.length - this.textHead > this.limit) { + const key = this.textOrder[this.textHead++]; this.textRecords.delete(key); } } diff --git a/apps/desktop/src/main/settings-store.ts b/apps/desktop/src/main/settings-store.ts index cf45c15..8287b36 100644 --- a/apps/desktop/src/main/settings-store.ts +++ b/apps/desktop/src/main/settings-store.ts @@ -58,12 +58,12 @@ async function persistSettings(snapshot: SettingsMap): Promise { ensureDir(); const tmp = join(TDAY_DIR, `.settings-${randomUUID()}.tmp`); try { - await writeFileAsync(tmp, JSON.stringify(snapshot, null, 2), 'utf8'); + await writeFileAsync(tmp, JSON.stringify(snapshot), 'utf8'); await renameAsync(tmp, SETTINGS_FILE); } catch { // Fallback: direct write (non-atomic but better than nothing) try { - writeFileSync(SETTINGS_FILE, JSON.stringify(snapshot, null, 2), 'utf8'); + writeFileSync(SETTINGS_FILE, JSON.stringify(snapshot), 'utf8'); } catch { /* ignore */ } } } diff --git a/apps/desktop/src/main/usage/session-cache.ts b/apps/desktop/src/main/usage/session-cache.ts index c96cf60..a412fbd 100644 --- a/apps/desktop/src/main/usage/session-cache.ts +++ b/apps/desktop/src/main/usage/session-cache.ts @@ -207,14 +207,14 @@ function statFiles(files: string[]): { fileCount: number; maxMtime: number } { return { fileCount: files.length, maxMtime }; } -/** Returns true if the agent has new or modified session files since last scan. */ -function isAgentDirty(watcher: AgentWatcher, index: SessionIndex): boolean { +/** Returns `{ dirty, files }` for the given watcher. */ +function isAgentDirty(watcher: AgentWatcher, index: SessionIndex): { dirty: boolean; files: string[] } { const entry = index.agents[watcher.agentId]; - if (!entry) return true; // never scanned yet - const files = watcher.collectFiles(); + if (!entry) return { dirty: true, files }; // never scanned yet + const { fileCount, maxMtime } = statFiles(files); - return fileCount !== entry.fileCount || maxMtime > entry.maxMtime; + return { dirty: fileCount !== entry.fileCount || maxMtime > entry.maxMtime, files }; } // ── Cache read / write ─────────────────────────────────────────────────────── @@ -292,7 +292,11 @@ async function doRefresh(): Promise { const index = loadIndex(); // Determine dirty agents using stat-only walk (no file reads yet). - const dirtyWatchers = AGENT_WATCHERS.filter((w) => isAgentDirty(w, index)); + const dirtyWatchers: Array<{ watcher: AgentWatcher; files: string[] }> = []; + for (const w of AGENT_WATCHERS) { + const { dirty, files } = isAgentDirty(w, index); + if (dirty) dirtyWatchers.push({ watcher: w, files }); + } if (dirtyWatchers.length === 0) return; // Load existing cache grouped by agentId. @@ -307,15 +311,14 @@ async function doRefresh(): Promise { } // Re-scan each dirty agent. - for (const watcher of dirtyWatchers) { + for (const { watcher, files } of dirtyWatchers) { // Yield to the event loop between agents so IPC and UI remain responsive. await new Promise((resolve) => setImmediate(resolve)); const freshRecords = watcher.scan(); agentRecords.set(watcher.agentId, freshRecords); - // Update dirty-check watermarks for this agent. - const files = watcher.collectFiles(); + // Update dirty-check watermarks for this agent (reuse already-stat'd file list). const { fileCount, maxMtime } = statFiles(files); index.agents[watcher.agentId] = { lastScanTs: Date.now(), diff --git a/apps/desktop/src/main/usage/store.ts b/apps/desktop/src/main/usage/store.ts index 1ed7516..a4298a6 100644 --- a/apps/desktop/src/main/usage/store.ts +++ b/apps/desktop/src/main/usage/store.ts @@ -35,8 +35,17 @@ function loadUserPricing(): Record { return {}; } +let _cachedPricing: Record | null = null; + function mergedPricing(): Record { - return { ...BUILTIN_PRICING, ...loadUserPricing() }; + if (_cachedPricing) return _cachedPricing; + _cachedPricing = { ...BUILTIN_PRICING, ...loadUserPricing() }; + return _cachedPricing; +} + +/** Invalidate the pricing cache (call after user updates pricing.json). */ +export function invalidatePricingCache(): void { + _cachedPricing = null; } /** Append one usage record to the JSONL log. Non-throwing. */ @@ -150,8 +159,12 @@ export function computeUsageSummary(records: UsageRecord[]): UsageSummary { const cacheHitRate = promptTokens > 0 ? totalCachedTokens / promptTokens : 0; // Compute token throughput: total tokens / span of actual records in minutes. - const tsValues = records.map((r) => r.ts); - const spanMs = tsValues.length > 1 ? Math.max(...tsValues) - Math.min(...tsValues) : 0; + let minTs = Infinity, maxTs = -Infinity; + for (const r of records) { + if (r.ts < minTs) minTs = r.ts; + if (r.ts > maxTs) maxTs = r.ts; + } + const spanMs = records.length > 1 ? maxTs - minTs : 0; const spanMin = Math.max(1, spanMs / 60_000); const throughputTokensPerMin = (totalInputTokens + totalOutputTokens) / spanMin; diff --git a/crates/tday-nativecore/src/find_image.rs b/crates/tday-nativecore/src/find_image.rs index e5b4b12..1f8ec1e 100644 --- a/crates/tday-nativecore/src/find_image.rs +++ b/crates/tday-nativecore/src/find_image.rs @@ -10,6 +10,7 @@ use image::{GrayImage, ImageReader}; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::io::Cursor; #[cfg(feature = "find_image_parallel")] @@ -85,10 +86,10 @@ pub fn find_image( None => (0, 0, ss_w, ss_h), }; - let search_view = if sr_x == 0 && sr_y == 0 && sr_w == ss_w && sr_h == ss_h { - screenshot.clone() + let search_view: Cow = if sr_x == 0 && sr_y == 0 && sr_w == ss_w && sr_h == ss_h { + Cow::Borrowed(&screenshot) } else { - image::imageops::crop_imm(&screenshot, sr_x, sr_y, sr_w, sr_h).to_image() + Cow::Owned(image::imageops::crop_imm(&screenshot, sr_x, sr_y, sr_w, sr_h).to_image()) }; // Build scale list @@ -134,8 +135,8 @@ pub fn find_image( if new_w == 0 || new_h == 0 { continue; } if new_w > sr_w || new_h > sr_h { continue; } - let scaled_t = image::imageops::resize(ref_t, new_w, new_h, image::imageops::FilterType::Lanczos3); - let scaled_m = ref_m.as_ref().map(|m| image::imageops::resize(m, new_w, new_h, image::imageops::FilterType::Lanczos3)); + let scaled_t = image::imageops::resize(ref_t, new_w, new_h, image::imageops::FilterType::Triangle); + let scaled_m = ref_m.as_ref().map(|m| image::imageops::resize(m, new_w, new_h, image::imageops::FilterType::Triangle)); // Scan with stride let match_w = sr_w - new_w; @@ -192,14 +193,28 @@ struct TemplateVals { mean: f64, norm: f64 } fn precompute_template(t: &GrayImage, mask: Option<&GrayImage>) -> TemplateVals { let (tw, th) = t.dimensions(); - let pixels: Vec = (0..th).flat_map(|y| (0..tw).map(move |x| (y, x))) - .filter(|&(y, x)| mask.map_or(true, |m| m.get_pixel(x, y).0[0] > 127u8)) - .map(|(y, x)| t.get_pixel(x, y).0[0] as f64) - .collect(); - - if pixels.is_empty() { return TemplateVals { mean: 0.0, norm: 1.0 }; } - let mean = pixels.iter().sum::() / pixels.len() as f64; - let norm = pixels.iter().map(|&v| (v - mean).powi(2)).sum::().sqrt(); + + // First pass: compute mean + let mut sum = 0.0f64; + let mut count = 0usize; + for y in 0..th { for x in 0..tw { + if mask.map_or(true, |m| m.get_pixel(x, y).0[0] > 127u8) { + sum += t.get_pixel(x, y).0[0] as f64; + count += 1; + } + }} + if count == 0 { return TemplateVals { mean: 0.0, norm: 1.0 }; } + let mean = sum / count as f64; + + // Second pass: compute norm + let mut norm_sq = 0.0f64; + for y in 0..th { for x in 0..tw { + if mask.map_or(true, |m| m.get_pixel(x, y).0[0] > 127u8) { + let v = t.get_pixel(x, y).0[0] as f64 - mean; + norm_sq += v * v; + } + }} + let norm = norm_sq.sqrt(); TemplateVals { mean, norm: if norm < 1e-9 { 1.0 } else { norm } } } diff --git a/crates/tday-nativecore/src/handlers/screenshot.rs b/crates/tday-nativecore/src/handlers/screenshot.rs index 3c89dfe..197225c 100644 --- a/crates/tday-nativecore/src/handlers/screenshot.rs +++ b/crates/tday-nativecore/src/handlers/screenshot.rs @@ -24,40 +24,35 @@ pub async fn handle_take_screenshot( ) -> Result { let window_id: Option = params.get("window_id").and_then(|v| v.as_u64()).map(|v| v as u32); - let (jpeg_bytes, origin_x, origin_y, scale, pw, ph) = tokio::task::spawn_blocking(move || { + let (jpeg_bytes, png_data, origin_x, origin_y, scale, pw, ph) = tokio::task::spawn_blocking(move || { match window_id { - Some(wid) => platform::capture_window_cg_jpeg(wid), + Some(wid) => { + let (jpeg, ox, oy, sc, pw, ph) = platform::capture_window_cg_jpeg(wid)?; + // Decode JPEG once to get PNG for the cache + let img = image::load_from_memory(&jpeg) + .map_err(|e| format!("jpeg→png: {e}"))?; + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|e| format!("png encode: {e}"))?; + Ok::<_, String>((jpeg, png, ox, oy, sc, pw, ph)) + } None => { - // Full screen JPEG + // Full screen: capture gives us PNG directly let ss = platform::capture_screen() .map_err(|e| format!("capture_screen: {e}"))?; - // encode screen capture as jpeg - let mut out = Vec::new(); + // Encode JPEG for sending to client let img = image::load_from_memory(&ss.png_data) .map_err(|e| format!("decode: {e}"))?; - img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Jpeg) + let mut jpeg = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg) .map_err(|e| format!("jpeg: {e}"))?; - Ok::<_, String>((out, ss.origin_x, ss.origin_y, ss.scale_factor, + Ok::<_, String>((jpeg, ss.png_data, ss.origin_x, ss.origin_y, ss.scale_factor, ss.pixel_width, ss.pixel_height)) } } }).await.map_err(|e| DevToolsError::Other(format!("task: {e}")))? .map_err(DevToolsError::Screenshot)?; - // Store PNG copy in cache (re-decode jpeg → png for find_image) - let png_data = tokio::task::spawn_blocking({ - let jb = jpeg_bytes.clone(); - move || { - let img = image::load_from_memory(&jb) - .map_err(|e| format!("jpeg→png: {e}"))?; - let mut png = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) - .map_err(|e| format!("png encode: {e}"))?; - Ok::<_, String>(png) - } - }).await.map_err(|e| DevToolsError::Other(format!("task: {e}")))? - .map_err(DevToolsError::Screenshot)?; - let meta = CacheMeta { origin_x, origin_y, scale, window_id, pixel_width: pw, pixel_height: ph }; let ss_id = ss_cache.write().await.store(png_data, meta); diff --git a/crates/tday-nativecore/src/platform/linux/mod.rs b/crates/tday-nativecore/src/platform/linux/mod.rs index 51082f2..ced48a0 100644 --- a/crates/tday-nativecore/src/platform/linux/mod.rs +++ b/crates/tday-nativecore/src/platform/linux/mod.rs @@ -28,6 +28,6 @@ pub use screenshot::{capture_region, capture_screen, capture_window, capture_win pub use atspi::{ AXRef, ax_click, ax_find_text, ax_perform_action, ax_select, ax_set_value, element_at_point, frontmost_pid, pid_for_window, raise_windows, resize_window_by_pid, - take_snapshot, ax_find_elements, ax_get_focused, + take_snapshot, ax_find_elements, ax_get_focused, ElementInfo, }; pub use window::{find_window_by_id_direct, find_windows_by_app, list_windows}; diff --git a/crates/tday-nativecore/src/platform/macos/mod.rs b/crates/tday-nativecore/src/platform/macos/mod.rs index 3dd1e08..c012224 100644 --- a/crates/tday-nativecore/src/platform/macos/mod.rs +++ b/crates/tday-nativecore/src/platform/macos/mod.rs @@ -14,7 +14,7 @@ pub mod window; pub use app::*; pub use ax::{AXRef, ax_click, ax_perform_action, ax_select, ax_set_value, element_at_point, find_text as ax_find_text, frontmost_pid, pid_for_window, raise_windows, take_snapshot, - resize_window_by_pid, ax_find_elements, ax_get_focused}; + resize_window_by_pid, ax_find_elements, ax_get_focused, ElementInfo}; pub use display::{backing_scale_for_point, get_displays, get_main_display, screenshot_px_to_screen}; pub use input::{check_accessibility, click, drag, get_cursor_position, move_mouse, press_key, scroll, type_text, MouseButton}; diff --git a/crates/tday-nativecore/src/platform/mod.rs b/crates/tday-nativecore/src/platform/mod.rs index 4d3d1fb..23f8851 100644 --- a/crates/tday-nativecore/src/platform/mod.rs +++ b/crates/tday-nativecore/src/platform/mod.rs @@ -27,7 +27,7 @@ pub use macos::{ // AX ax_click, ax_find_text, ax_perform_action, ax_select, ax_set_value, element_at_point, frontmost_pid, pid_for_window, raise_windows, resize_window_by_pid, take_snapshot, AXRef, - ax_find_elements, ax_get_focused, + ax_find_elements, ax_get_focused, ElementInfo, // Display backing_scale_for_point, get_displays, get_main_display, screenshot_px_to_screen, // Input @@ -55,7 +55,7 @@ pub use windows::{ // AX ax_click, ax_find_text, ax_perform_action, ax_select, ax_set_value, element_at_point, frontmost_pid, pid_for_window, raise_windows, resize_window_by_pid, take_snapshot, AXRef, - ax_find_elements, ax_get_focused, + ax_find_elements, ax_get_focused, ElementInfo, // Display backing_scale_for_point, get_displays, get_main_display, screenshot_px_to_screen, // Input @@ -83,7 +83,7 @@ pub use linux::{ // AX ax_click, ax_find_text, ax_perform_action, ax_select, ax_set_value, element_at_point, frontmost_pid, pid_for_window, raise_windows, resize_window_by_pid, take_snapshot, AXRef, - ax_find_elements, ax_get_focused, + ax_find_elements, ax_get_focused, ElementInfo, // Display backing_scale_for_point, get_displays, get_main_display, screenshot_px_to_screen, // Input diff --git a/crates/tday-nativecore/src/platform/windows/mod.rs b/crates/tday-nativecore/src/platform/windows/mod.rs index 15e581d..691334f 100644 --- a/crates/tday-nativecore/src/platform/windows/mod.rs +++ b/crates/tday-nativecore/src/platform/windows/mod.rs @@ -28,7 +28,7 @@ pub use screenshot::{capture_region, capture_screen, capture_window, capture_win pub use uia::{ AXRef, ax_click, ax_find_text, ax_perform_action, ax_select, ax_set_value, element_at_point, frontmost_pid, pid_for_window, raise_windows, resize_window_by_pid, - take_snapshot, ax_find_elements, ax_get_focused, + take_snapshot, ax_find_elements, ax_get_focused, ElementInfo, }; pub use window::{find_window_by_id_direct, find_windows_by_app, list_windows}; diff --git a/crates/tday-nativecore/src/session/image_cache.rs b/crates/tday-nativecore/src/session/image_cache.rs index 04d7fda..df2585a 100644 --- a/crates/tday-nativecore/src/session/image_cache.rs +++ b/crates/tday-nativecore/src/session/image_cache.rs @@ -4,7 +4,7 @@ /// LRU image cache for find_image template images (load_image tool). -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; const MAX_ENTRIES: usize = 20; @@ -14,33 +14,43 @@ pub struct CachedImage { pub png_data: Vec, } -#[derive(Default)] +/// LRU cache: `map` provides O(1) lookup by id; `order` is a VecDeque of ids +/// in insertion/access order (front = oldest, back = newest). pub struct ImageCache { - entries: VecDeque, + map: HashMap>, + order: VecDeque, counter: u64, } +impl Default for ImageCache { + fn default() -> Self { + Self { map: HashMap::new(), order: VecDeque::new(), counter: 0 } + } +} + impl ImageCache { /// Store an image and return its generated ID. pub fn store(&mut self, png_data: Vec) -> String { self.counter += 1; let id = format!("img_{}", self.counter); - if self.entries.len() >= MAX_ENTRIES { - self.entries.pop_front(); + if self.map.len() >= MAX_ENTRIES { + if let Some(evicted) = self.order.pop_front() { + self.map.remove(&evicted); + } } - self.entries.push_back(CachedImage { id: id.clone(), png_data }); + self.map.insert(id.clone(), png_data); + self.order.push_back(id.clone()); id } - /// Get (LRU bump via move-to-back). + /// Get with LRU bump (move to back of order queue). pub fn get(&mut self, id: &str) -> Option { - if let Some(pos) = self.entries.iter().position(|e| e.id == id) { - let entry = self.entries.remove(pos)?; - let cloned = entry.clone(); - self.entries.push_back(entry); - Some(cloned) - } else { - None + let png_data = self.map.get(id)?.clone(); + // Move to back: remove from current position (O(n)) and push to back. + if let Some(pos) = self.order.iter().position(|k| k == id) { + self.order.remove(pos); } + self.order.push_back(id.to_string()); + Some(CachedImage { id: id.to_string(), png_data }) } } diff --git a/crates/tday-nativecore/src/session/screenshot_cache.rs b/crates/tday-nativecore/src/session/screenshot_cache.rs index cc9e16b..2b61349 100644 --- a/crates/tday-nativecore/src/session/screenshot_cache.rs +++ b/crates/tday-nativecore/src/session/screenshot_cache.rs @@ -4,7 +4,7 @@ /// LRU screenshot cache for find_image and coordinate re-use. -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; const MAX_ENTRIES: usize = 10; @@ -26,26 +26,36 @@ pub struct CachedScreenshot { pub metadata: ScreenshotMeta, } -#[derive(Default)] +/// Cache: `map` provides O(1) lookup by id; `order` tracks eviction order. pub struct ScreenshotCache { - entries: VecDeque, + map: HashMap, + order: VecDeque, counter: u64, } +impl Default for ScreenshotCache { + fn default() -> Self { + Self { map: HashMap::new(), order: VecDeque::new(), counter: 0 } + } +} + impl ScreenshotCache { /// Store a screenshot and return its generated ID. pub fn store(&mut self, png_data: Vec, meta: ScreenshotMeta) -> String { self.counter += 1; let id = format!("ss_{}", self.counter); - if self.entries.len() >= MAX_ENTRIES { - self.entries.pop_front(); + if self.map.len() >= MAX_ENTRIES { + if let Some(evicted) = self.order.pop_front() { + self.map.remove(&evicted); + } } - self.entries.push_back(CachedScreenshot { id: id.clone(), png_data, metadata: meta }); + self.order.push_back(id.clone()); + self.map.insert(id.clone(), CachedScreenshot { id: id.clone(), png_data, metadata: meta }); id } - /// Peek (no LRU bump) — used by find_image which clones the data immediately. + /// Peek (no LRU bump) — O(1) via HashMap. pub fn peek(&self, id: &str) -> Option<&CachedScreenshot> { - self.entries.iter().find(|e| e.id == id) + self.map.get(id) } } diff --git a/crates/tday-nativecore/src/tracking/hover_tracker.rs b/crates/tday-nativecore/src/tracking/hover_tracker.rs index a70817e..2710145 100644 --- a/crates/tday-nativecore/src/tracking/hover_tracker.rs +++ b/crates/tday-nativecore/src/tracking/hover_tracker.rs @@ -262,8 +262,7 @@ fn element_at_point_for_hover( #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] { let info = crate::platform::element_at_point(x, y, app_name)?; - let value = serde_json::to_value(&info).map_err(|e| e.to_string())?; - Ok(parse_hover_element(&value)) + Ok(hover_element_from_info(info)) } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { @@ -272,6 +271,18 @@ fn element_at_point_for_hover( } } +#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] +fn hover_element_from_info(info: crate::platform::ElementInfo) -> HoverElement { + HoverElement { + name: info.name.map(|s| truncate_field(&s)), + role: info.role.map(|s| truncate_field(&s)), + label: info.description.map(|s| truncate_field(&s)), + bounds: info.bounds.map(|r| ElementBounds { x: r.x, y: r.y, width: r.width, height: r.height }), + app_name: info.app_name.map(|s| truncate_field(&s)), + pid: Some(info.pid), + } +} + #[cfg(test)] mod tests { use super::*;