Skip to content

Commit 423e669

Browse files
committed
AI settings: fix and extract AI gauge logic
1 parent abfc248 commit 423e669

File tree

5 files changed

+326
-80
lines changed

5 files changed

+326
-80
lines changed

apps/desktop/src-tauri/src/ai/manager.rs

Lines changed: 129 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -364,28 +364,107 @@ pub fn get_ai_runtime_status() -> AiRuntimeStatus {
364364
}
365365
}
366366

367-
/// System memory info returned to frontend for the RAM gauge.
368-
#[derive(Debug, Clone, serde::Serialize)]
367+
/// System memory breakdown returned to frontend for the RAM gauge.
368+
/// Categories are non-overlapping and sum to `total_bytes`.
369+
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
369370
#[serde(rename_all = "camelCase")]
370371
pub struct SystemMemoryInfo {
371372
pub total_bytes: u64,
372-
/// Memory actively used by processes (app + wired + compressed on macOS).
373-
pub used_bytes: u64,
374-
/// Memory available for new allocations (free + inactive + purgeable on macOS).
375-
pub available_bytes: u64,
373+
/// Wired + compressor-occupied memory (kernel, drivers — can't be freed).
374+
pub wired_bytes: u64,
375+
/// App memory: active + inactive - purgeable (process memory the user can free by quitting apps).
376+
pub app_bytes: u64,
377+
/// Free: free + purgeable + speculative (available for new allocations).
378+
pub free_bytes: u64,
376379
}
377380

378-
/// Returns system memory info (total, used by processes, and available).
379-
/// Uses the `sysinfo` crate for cross-platform accuracy.
381+
/// Returns system memory breakdown using macOS `host_statistics64` for accurate,
382+
/// non-overlapping categories (unlike `sysinfo` where used + available > total).
380383
#[tauri::command]
381384
pub fn get_system_memory_info() -> SystemMemoryInfo {
382-
let mut sys = sysinfo::System::new();
383-
sys.refresh_memory();
384-
SystemMemoryInfo {
385-
total_bytes: sys.total_memory(),
386-
used_bytes: sys.used_memory(),
387-
available_bytes: sys.available_memory(),
385+
get_system_memory_info_inner()
386+
}
387+
388+
/// Testable inner function that reads macOS vm_statistics64 via Mach API.
389+
pub fn get_system_memory_info_inner() -> SystemMemoryInfo {
390+
#[cfg(target_os = "macos")]
391+
{
392+
macos_memory_info()
393+
}
394+
#[cfg(not(target_os = "macos"))]
395+
{
396+
// Fallback for non-macOS: use sysinfo (best effort)
397+
let mut sys = sysinfo::System::new();
398+
sys.refresh_memory();
399+
let total = sys.total_memory();
400+
let used = sys.used_memory();
401+
let free = total.saturating_sub(used);
402+
SystemMemoryInfo {
403+
total_bytes: total,
404+
wired_bytes: 0,
405+
app_bytes: used,
406+
free_bytes: free,
407+
}
408+
}
409+
}
410+
411+
/// Reads macOS vm_statistics64 via `host_statistics64` for accurate memory breakdown.
412+
#[cfg(target_os = "macos")]
413+
fn macos_memory_info() -> SystemMemoryInfo {
414+
use std::mem;
415+
416+
let total_bytes = {
417+
let mut sys = sysinfo::System::new();
418+
sys.refresh_memory();
419+
sys.total_memory()
420+
};
421+
422+
// Safety: calling Mach kernel API with proper struct size.
423+
let page_size: u64;
424+
let (wired_pages, compressor_pages, internal_pages, purgeable_pages);
425+
426+
unsafe {
427+
page_size = libc::sysconf(libc::_SC_PAGESIZE) as u64;
428+
429+
#[allow(deprecated, reason = "libc says use mach2, but not worth a new dep for one call")]
430+
let host = libc::mach_host_self();
431+
let mut vm_info: libc::vm_statistics64 = mem::zeroed();
432+
let mut count = (size_of::<libc::vm_statistics64>() / size_of::<libc::integer_t>()) as u32;
433+
434+
let ret = libc::host_statistics64(
435+
host,
436+
libc::HOST_VM_INFO64,
437+
&mut vm_info as *mut _ as *mut libc::integer_t,
438+
&mut count,
439+
);
440+
441+
if ret != libc::KERN_SUCCESS {
442+
log::warn!("host_statistics64 returned {ret}, falling back to sysinfo");
443+
let mut sys = sysinfo::System::new();
444+
sys.refresh_memory();
445+
let used = sys.used_memory();
446+
return SystemMemoryInfo {
447+
total_bytes,
448+
wired_bytes: 0,
449+
app_bytes: used,
450+
free_bytes: total_bytes.saturating_sub(used),
451+
};
452+
}
453+
454+
wired_pages = vm_info.wire_count as u64;
455+
compressor_pages = vm_info.compressor_page_count as u64;
456+
// internal_page_count = anonymous pages owned by processes (what Activity Monitor calls "App Memory").
457+
// Unlike active+inactive, this excludes file-backed cache that macOS freely reclaims.
458+
internal_pages = vm_info.internal_page_count as u64;
459+
purgeable_pages = vm_info.purgeable_count as u64;
388460
}
461+
462+
let wired_bytes = (wired_pages + compressor_pages) * page_size;
463+
let app_bytes = internal_pages.saturating_sub(purgeable_pages) * page_size;
464+
// Free = everything not wired or app (includes file cache, inactive, purgeable, speculative)
465+
let free_bytes = total_bytes.saturating_sub(wired_bytes + app_bytes);
466+
467+
SystemMemoryInfo { total_bytes, wired_bytes, app_bytes, free_bytes }
389468
}
390469

391470
/// Stores provider + context size + OpenAI config in manager state.
@@ -980,4 +1059,40 @@ mod tests {
9801059
let status = get_ai_status();
9811060
assert_eq!(status, AiStatus::Unavailable);
9821061
}
1062+
1063+
#[test]
1064+
fn test_system_memory_info_adds_up() {
1065+
let info = get_system_memory_info_inner();
1066+
1067+
// Total must be positive (every machine has RAM)
1068+
assert!(info.total_bytes > 0, "total_bytes should be positive");
1069+
1070+
// Non-overlapping segments must sum to total
1071+
let sum = info.wired_bytes + info.app_bytes + info.free_bytes;
1072+
assert_eq!(
1073+
sum, info.total_bytes,
1074+
"wired ({}) + app ({}) + free ({}) = {} != total ({})",
1075+
info.wired_bytes, info.app_bytes, info.free_bytes, sum, info.total_bytes,
1076+
);
1077+
1078+
// Each segment should be reasonable (not more than total)
1079+
assert!(info.wired_bytes <= info.total_bytes);
1080+
assert!(info.app_bytes <= info.total_bytes);
1081+
assert!(info.free_bytes <= info.total_bytes);
1082+
}
1083+
1084+
#[test]
1085+
fn test_system_memory_info_serialization() {
1086+
let info = SystemMemoryInfo {
1087+
total_bytes: 68_719_476_736,
1088+
wired_bytes: 5_000_000_000,
1089+
app_bytes: 30_000_000_000,
1090+
free_bytes: 33_719_476_736,
1091+
};
1092+
let json = serde_json::to_string(&info).unwrap();
1093+
assert!(json.contains("\"totalBytes\":68719476736"));
1094+
assert!(json.contains("\"wiredBytes\":5000000000"));
1095+
assert!(json.contains("\"appBytes\":30000000000"));
1096+
assert!(json.contains("\"freeBytes\":33719476736"));
1097+
}
9831098
}

apps/desktop/src/lib/settings/sections/AiSection.svelte

Lines changed: 5 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
type SystemMemoryInfo,
3636
} from '$lib/tauri-commands'
3737
import { createShouldShow } from '$lib/settings/settings-search'
38+
import { computeGaugeSegments } from './ram-gauge-utils'
3839
import { getAppLogger } from '$lib/logging/logger'
3940
4041
interface Props {
@@ -565,66 +566,9 @@
565566
const showApplyButton = $derived(pendingContextSize !== activeContextSize && serverRunning && !isRestarting)
566567
567568
// RAM gauge segments (percentages of total RAM)
568-
// Left-to-right: other | retained AI | change (freed or added) | free (bar background)
569-
// "retained AI" = AI memory that stays after applying the new context size
570-
// "change" = freed (shrinking, green) or added (growing, gold 50%)
571-
// Segments: System | Other apps | Cmdr AI (retained/added/freed) | Free
572-
// All segments sum to <= 100%; remainder is the bar background (free memory).
573-
const gaugeSegments = $derived.by(() => {
574-
if (!systemMemory || systemMemory.totalBytes === 0) return null
575-
576-
const total = systemMemory.totalBytes
577-
const usedByProcesses = systemMemory.usedBytes
578-
const availableBytes = systemMemory.availableBytes
579-
580-
// System = kernel overhead, wired, compressed (not attributed to any process)
581-
const systemBytes = Math.max(0, total - usedByProcesses - availableBytes)
582-
// Other apps = all process memory minus our AI estimate
583-
const otherAppsBytes = Math.max(0, usedByProcesses - currentAiMemoryBytes)
584-
585-
const systemPercent = (systemBytes / total) * 100
586-
const otherAppsPercent = (otherAppsBytes / total) * 100
587-
const delta = projectedAiMemoryBytes - currentAiMemoryBytes
588-
589-
// When shrinking: split current AI into "retained" (projected) + "freed" (|delta|)
590-
// When growing: current AI stays, delta is added after it
591-
// When unchanged: just current AI
592-
let retainedAiPercent: number
593-
let addedPercent: number
594-
let freedPercent: number
595-
596-
if (delta > 0) {
597-
retainedAiPercent = (currentAiMemoryBytes / total) * 100
598-
addedPercent = (delta / total) * 100
599-
freedPercent = 0
600-
} else if (delta < 0) {
601-
retainedAiPercent = (projectedAiMemoryBytes / total) * 100
602-
addedPercent = 0
603-
freedPercent = (Math.abs(delta) / total) * 100
604-
} else {
605-
retainedAiPercent = (currentAiMemoryBytes / total) * 100
606-
addedPercent = 0
607-
freedPercent = 0
608-
}
609-
610-
// Clamp so segments never exceed 100% total
611-
const segmentTotal = systemPercent + otherAppsPercent + retainedAiPercent + addedPercent + freedPercent
612-
const scale = segmentTotal > 100 ? 100 / segmentTotal : 1
613-
614-
const totalProjectedUsage = systemBytes + otherAppsBytes + projectedAiMemoryBytes
615-
616-
return {
617-
systemPercent: systemPercent * scale,
618-
otherAppsPercent: otherAppsPercent * scale,
619-
retainedAiPercent: retainedAiPercent * scale,
620-
addedPercent: addedPercent * scale,
621-
freedPercent: freedPercent * scale,
622-
totalProjectedUsageRatio: totalProjectedUsage / total,
623-
systemBytes,
624-
otherAppsBytes,
625-
availableBytes,
626-
}
627-
})
569+
const gaugeSegments = $derived(
570+
systemMemory ? computeGaugeSegments(systemMemory, currentAiMemoryBytes, projectedAiMemoryBytes) : null,
571+
)
628572
629573
// Warning state based on projected usage
630574
const warningLevel = $derived.by((): 'none' | 'caution' | 'danger' => {
@@ -1127,7 +1071,7 @@
11271071
{/if}
11281072
<span class="ram-legend-item"
11291073
><span class="ram-legend-swatch ram-free-space"></span>Free {formatMemoryGb(
1130-
gaugeSegments.availableBytes,
1074+
gaugeSegments.freeBytes,
11311075
)}</span
11321076
>
11331077
</div>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { computeGaugeSegments, type GaugeSegments } from './ram-gauge-utils'
3+
import type { SystemMemoryInfo } from '$lib/tauri-commands'
4+
5+
const GB = 1024 * 1024 * 1024
6+
7+
/** Helper: builds a SystemMemoryInfo where segments sum to total. */
8+
function mem(totalGb: number, wiredGb: number, appGb: number, freeGb: number): SystemMemoryInfo {
9+
return { totalBytes: totalGb * GB, wiredBytes: wiredGb * GB, appBytes: appGb * GB, freeBytes: freeGb * GB }
10+
}
11+
12+
/** Asserts the result is non-null and returns it typed. */
13+
function expectSegments(memory: SystemMemoryInfo, currentAi: number, projectedAi: number): GaugeSegments {
14+
const result = computeGaugeSegments(memory, currentAi, projectedAi)
15+
expect(result).not.toBeNull()
16+
return result as GaugeSegments
17+
}
18+
19+
describe('computeGaugeSegments', () => {
20+
it('returns null when total is 0', () => {
21+
expect(computeGaugeSegments(mem(0, 0, 0, 0), 0, 0)).toBeNull()
22+
})
23+
24+
it('segments add up to <= 100%', () => {
25+
const result = expectSegments(mem(64, 5, 30, 29), 3.5 * GB, 3.5 * GB)
26+
const sum =
27+
result.systemPercent +
28+
result.otherAppsPercent +
29+
result.retainedAiPercent +
30+
result.addedPercent +
31+
result.freedPercent
32+
expect(sum).toBeLessThanOrEqual(100.01) // floating point tolerance
33+
expect(sum).toBeGreaterThan(0)
34+
})
35+
36+
it('system segment uses wired bytes (non-zero for real systems)', () => {
37+
const result = expectSegments(mem(64, 5, 30, 29), 3.5 * GB, 3.5 * GB)
38+
expect(result.systemBytes).toBe(5 * GB)
39+
expect(result.systemPercent).toBeCloseTo((5 / 64) * 100, 1)
40+
})
41+
42+
it('other apps = app memory minus AI memory', () => {
43+
const result = expectSegments(mem(64, 5, 30, 29), 3.5 * GB, 3.5 * GB)
44+
expect(result.otherAppsBytes).toBe(26.5 * GB)
45+
})
46+
47+
it('free bytes come directly from system memory', () => {
48+
const result = expectSegments(mem(64, 5, 30, 29), 3.5 * GB, 3.5 * GB)
49+
expect(result.freeBytes).toBe(29 * GB)
50+
})
51+
52+
it('other apps bytes are clamped to 0 when AI estimate exceeds app memory', () => {
53+
// AI estimate larger than reported app memory (edge case during model load)
54+
const result = expectSegments(mem(64, 5, 2, 57), 3.5 * GB, 3.5 * GB)
55+
expect(result.otherAppsBytes).toBe(0)
56+
})
57+
58+
it('shows added segment when projected > current (growing)', () => {
59+
const current = 2 * GB
60+
const projected = 4 * GB
61+
const result = expectSegments(mem(64, 5, 30, 29), current, projected)
62+
expect(result.addedPercent).toBeGreaterThan(0)
63+
expect(result.freedPercent).toBe(0)
64+
expect(result.retainedAiPercent).toBeCloseTo((2 / 64) * 100, 1)
65+
expect(result.addedPercent).toBeCloseTo((2 / 64) * 100, 1)
66+
})
67+
68+
it('shows freed segment when projected < current (shrinking)', () => {
69+
const current = 4 * GB
70+
const projected = 2 * GB
71+
const result = expectSegments(mem(64, 5, 30, 29), current, projected)
72+
expect(result.freedPercent).toBeGreaterThan(0)
73+
expect(result.addedPercent).toBe(0)
74+
expect(result.retainedAiPercent).toBeCloseTo((2 / 64) * 100, 1)
75+
expect(result.freedPercent).toBeCloseTo((2 / 64) * 100, 1)
76+
})
77+
78+
it('no change segments when projected == current', () => {
79+
const result = expectSegments(mem(64, 5, 30, 29), 3 * GB, 3 * GB)
80+
expect(result.addedPercent).toBe(0)
81+
expect(result.freedPercent).toBe(0)
82+
})
83+
84+
it('clamps to 100% when segments would overflow', () => {
85+
// Extreme case: all memory categories are huge relative to total
86+
const result = expectSegments(mem(16, 4, 10, 2), 6 * GB, 10 * GB)
87+
const sum =
88+
result.systemPercent +
89+
result.otherAppsPercent +
90+
result.retainedAiPercent +
91+
result.addedPercent +
92+
result.freedPercent
93+
expect(sum).toBeLessThanOrEqual(100.01)
94+
})
95+
96+
it('totalProjectedUsageRatio reflects projected AI, not current', () => {
97+
const current = 2 * GB
98+
const projected = 6 * GB
99+
const result = expectSegments(mem(64, 5, 30, 29), current, projected)
100+
// system(5) + otherApps(30-2=28) + projected(6) = 39 / 64 ≈ 0.609
101+
expect(result.totalProjectedUsageRatio).toBeCloseTo(39 / 64, 2)
102+
})
103+
104+
it('AI server not running (0 current) shows only projected as added', () => {
105+
const result = expectSegments(mem(64, 5, 30, 29), 0, 3.5 * GB)
106+
expect(result.retainedAiPercent).toBe(0)
107+
expect(result.addedPercent).toBeCloseTo((3.5 / 64) * 100, 1)
108+
// Other apps = full app memory since AI current is 0
109+
expect(result.otherAppsBytes).toBe(30 * GB)
110+
})
111+
})

0 commit comments

Comments
 (0)