Skip to content

Commit 9541fcd

Browse files
committed
feat: Ultra Lean Mode improvements for v1.4.0
- Add compactor module with code block dehighlighting - Implement typing guard to prevent trim during input - Add mutation relevance filtering (skip composer mutations) - Always use fast path in ultraLean mode (zero layout reads) - Add watchdog + refcount for robust suppression handling - Status bar throttling with change detection - CSS containment with fallback for non-trimmer scenarios - Mark Ultra Lean as Experimental in UI
1 parent 59e56ed commit 9541fcd

File tree

19 files changed

+1830
-86
lines changed

19 files changed

+1830
-86
lines changed

extension/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "LightSession for ChatGPT",
4-
"version": "1.3.0",
4+
"version": "1.4.0",
55
"description": "Keep ChatGPT fast by keeping only the last N messages in the DOM. Local-only.",
66
"icons": {
77
"16": "icons/icon-16.png",

extension/popup/popup.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,20 @@ main {
138138
font-weight: 500;
139139
}
140140

141+
.experimental-badge {
142+
display: inline-block;
143+
font-size: 10px;
144+
font-weight: 500;
145+
color: #92400e;
146+
background: #fef3c7;
147+
padding: 2px 8px;
148+
border-radius: 9999px;
149+
margin-left: 6px;
150+
vertical-align: middle;
151+
text-transform: uppercase;
152+
letter-spacing: 0.3px;
153+
}
154+
141155
/* Range Slider */
142156
#keepSlider {
143157
width: 100%;

extension/popup/popup.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ <h1>LightSession</h1>
5757
<p class="setting-description">Display trimming statistics on the ChatGPT page</p>
5858
</div>
5959

60+
<!-- Ultra Lean Mode -->
61+
<div class="setting-group">
62+
<label class="checkbox-container">
63+
<input type="checkbox" id="ultraLeanCheckbox" aria-label="Enable Ultra Lean mode">
64+
<span class="checkbox-label">Ultra Lean Mode <span class="experimental-badge">Experimental</span></span>
65+
</label>
66+
<p class="setting-description">Aggressive performance mode: kills animations, reduces input lag, dehighlights old code blocks</p>
67+
</div>
68+
6069
<!-- Debug Mode (only shown in dev mode) -->
6170
<div class="setting-group" id="debugGroup" style="display: none;">
6271
<label class="checkbox-container">

extension/src/content/compactor.ts

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
/**
2+
* LightSession - Compactor Module
3+
*
4+
* Post-trim optimization to simplify kept messages.
5+
* Dehighlights syntax-highlighted code blocks to reduce DOM complexity.
6+
*
7+
* Runs via requestIdleCallback to avoid blocking the main thread.
8+
* Uses WeakSet to track processed nodes - automatically cleaned up by GC
9+
* when nodes are removed from DOM.
10+
*/
11+
12+
import { logDebug, logInfo, isDebugMode } from '../shared/logger';
13+
14+
// Track processed nodes to avoid re-processing
15+
// WeakSet automatically removes references when DOM nodes are garbage collected
16+
const processedNodes = new WeakSet<HTMLElement>();
17+
18+
// Mutation suppression - refcount-based with watchdog safety
19+
// Refcount: tracks overlapping runCompactor() calls
20+
// Watchdog: clears stuck suppression if extendSuppression() stops being called
21+
let activeRuns = 0;
22+
let mutationSuppressionUntil = 0;
23+
const MUTATION_SUPPRESSION_SAFETY_MS = 1500; // Safety margin for heavy code blocks
24+
let watchdogTimer: number | undefined;
25+
26+
/**
27+
* Arm the watchdog timer.
28+
* If extendSuppression() isn't called within the safety window, watchdog clears suppression.
29+
* This prevents stuck suppression if compactor crashes/errors without cleanup.
30+
*/
31+
function armWatchdog(): void {
32+
if (watchdogTimer !== undefined) {
33+
window.clearTimeout(watchdogTimer);
34+
}
35+
watchdogTimer = window.setTimeout(() => {
36+
if (activeRuns === 0) return; // Already cleaned up
37+
logDebug(`Compactor: Watchdog cleared stuck suppression (activeRuns was ${activeRuns})`);
38+
activeRuns = 0;
39+
mutationSuppressionUntil = 0;
40+
watchdogTimer = undefined;
41+
}, MUTATION_SUPPRESSION_SAFETY_MS + 250); // 250ms grace period
42+
}
43+
44+
/**
45+
* Clear the watchdog timer.
46+
*/
47+
function clearWatchdog(): void {
48+
if (watchdogTimer !== undefined) {
49+
window.clearTimeout(watchdogTimer);
50+
watchdogTimer = undefined;
51+
}
52+
}
53+
54+
/**
55+
* Extend the suppression timeout and re-arm watchdog.
56+
* Called periodically during processing to prevent timeout during heavy work.
57+
*/
58+
function extendSuppression(): void {
59+
mutationSuppressionUntil = performance.now() + MUTATION_SUPPRESSION_SAFETY_MS;
60+
armWatchdog();
61+
}
62+
63+
/**
64+
* Check if mutations should be suppressed (compactor is working).
65+
* Uses OR logic: suppressed if any run is active OR within safety window.
66+
* Watchdog handles stuck flags by clearing activeRuns if no ping received.
67+
* @returns true if mutations should be ignored
68+
*/
69+
export function isMutationSuppressed(): boolean {
70+
return activeRuns > 0 || performance.now() < mutationSuppressionUntil;
71+
}
72+
73+
/**
74+
* Start suppressing mutations.
75+
* Called when compactor begins work. Supports overlapping runs via refcount.
76+
*/
77+
function startMutationSuppression(): void {
78+
activeRuns++;
79+
mutationSuppressionUntil = performance.now() + MUTATION_SUPPRESSION_SAFETY_MS;
80+
armWatchdog();
81+
logDebug(`Compactor: Mutation suppression started (activeRuns: ${activeRuns})`);
82+
}
83+
84+
/**
85+
* Stop suppressing mutations.
86+
* Called when compactor finishes work. Only fully stops when all runs complete.
87+
*/
88+
function stopMutationSuppression(): void {
89+
activeRuns = Math.max(0, activeRuns - 1);
90+
if (activeRuns === 0) {
91+
mutationSuppressionUntil = 0;
92+
clearWatchdog();
93+
logDebug('Compactor: Mutation suppression ended (all runs complete)');
94+
} else {
95+
logDebug(`Compactor: Run finished, ${activeRuns} still active`);
96+
}
97+
}
98+
99+
// Selectors for syntax-highlighted code blocks
100+
// These are common patterns used by ChatGPT and highlight.js
101+
// Note: :has() is NOT used - it throws DOMException on older browsers
102+
const CODE_BLOCK_SELECTORS = [
103+
// Highlight.js patterns
104+
'pre code[class*="hljs"]',
105+
'pre code[class*="language-"]',
106+
'pre[class*="hljs"]',
107+
// ChatGPT specific patterns
108+
'.code-block pre',
109+
'[data-testid*="code-block"] pre',
110+
'pre.code-block',
111+
'div[class*="code-block"] pre',
112+
// Broader patterns for ChatGPT's current structure
113+
'[class*="overflow-y-auto"] pre', // Code container with scroll
114+
'[class*="bg-black"] pre', // Dark background code blocks
115+
// Fallback: any pre with code child (will be filtered by span count)
116+
'pre code',
117+
];
118+
119+
// Minimum number of spans to consider a code block "highlighted"
120+
const MIN_SPANS_FOR_DEHIGHLIGHT = 5;
121+
122+
/**
123+
* Replace syntax-highlighted code with plain text.
124+
* Preserves the code content, removes highlighting spans.
125+
*
126+
* Expects canonical element (pre) - caller handles normalization.
127+
*
128+
* @param preElement The canonical pre element to dehighlight
129+
* @returns true if dehighlighting was performed, false otherwise
130+
*/
131+
function dehighlightCodeBlock(preElement: HTMLElement): boolean {
132+
// Skip if already processed (WeakSet tracks across all calls)
133+
if (processedNodes.has(preElement)) {
134+
return false;
135+
}
136+
137+
// Mark as processed before any work
138+
processedNodes.add(preElement);
139+
140+
// Count spans before (for logging and threshold check)
141+
const spanCount = preElement.querySelectorAll('span').length;
142+
143+
if (spanCount < MIN_SPANS_FOR_DEHIGHLIGHT) {
144+
// Not significantly highlighted, skip
145+
logDebug(`Skipping code block with ${spanCount} spans (threshold: ${MIN_SPANS_FOR_DEHIGHLIGHT})`);
146+
return false;
147+
}
148+
149+
// Find the innermost code element if this is a pre > code structure
150+
const codeChild = preElement.querySelector('code');
151+
const targetElement = codeChild ?? preElement;
152+
153+
// Get plain text from codeChild if available, otherwise from pre
154+
// This avoids capturing "Copy code" buttons or other UI elements inside pre
155+
const plainText = codeChild?.textContent ?? preElement.textContent ?? '';
156+
157+
// Replace innerHTML with plain text
158+
// This removes all highlighting spans while preserving the text
159+
targetElement.textContent = plainText;
160+
161+
// Add a marker class so we know this was dehighlighted
162+
targetElement.classList.add('ls-dehighlighted');
163+
164+
logDebug(`Dehighlighted code block: removed ${spanCount} spans`);
165+
return true;
166+
}
167+
168+
// Pre-joined selector for single querySelectorAll (faster than N separate queries)
169+
const CODE_BLOCK_SELECTOR_JOINED = CODE_BLOCK_SELECTORS.join(',');
170+
171+
/**
172+
* Process code blocks in the given container.
173+
* Uses single querySelectorAll with joined selectors for performance.
174+
* Deduplicates to canonical <pre> elements before processing to avoid
175+
* function call overhead when selectors match both pre and pre>code.
176+
*
177+
* @param container The container element to search within
178+
* @returns Number of code blocks that were dehighlighted
179+
*/
180+
function processCodeBlocks(container: HTMLElement): number {
181+
// Single query with all selectors joined - faster than N separate queries
182+
const matches = container.querySelectorAll<HTMLElement>(CODE_BLOCK_SELECTOR_JOINED);
183+
184+
// Deduplicate to canonical <pre> elements before processing
185+
// This avoids function call overhead when selectors match both `pre` and `pre>code`
186+
const uniquePres = new Set<HTMLElement>();
187+
for (let i = 0; i < matches.length; i++) {
188+
const el = matches[i];
189+
if (!el) continue;
190+
// Normalize to <pre> if possible, otherwise use the element itself
191+
const canonical = el.tagName === 'PRE' ? el : (el.closest('pre') as HTMLElement | null);
192+
uniquePres.add(canonical ?? el);
193+
}
194+
195+
// Process unique elements
196+
let processed = 0;
197+
for (const block of uniquePres) {
198+
if (dehighlightCodeBlock(block)) {
199+
processed++;
200+
}
201+
}
202+
203+
// Debug: log what we found (only if debug mode AND no matches)
204+
if (matches.length === 0 && isDebugMode()) {
205+
const anyPre = container.querySelectorAll('pre');
206+
const anyCode = container.querySelectorAll('code');
207+
if (anyPre.length > 0 || anyCode.length > 0) {
208+
logDebug(`Compactor: Found ${anyPre.length} <pre>, ${anyCode.length} <code>, but none matched selectors`);
209+
if (anyPre.length > 0) {
210+
const firstPre = anyPre[0];
211+
logDebug(`Compactor: First <pre> classes: "${firstPre?.className}", parent: "${firstPre?.parentElement?.className}"`);
212+
}
213+
}
214+
}
215+
216+
return processed;
217+
}
218+
219+
// Number of most recent messages to skip (preserve readability of latest responses)
220+
const SKIP_LAST_N_MESSAGES = 2;
221+
222+
/**
223+
* Run compactor on kept messages via requestIdleCallback.
224+
* This is the main entry point called after trim completes.
225+
*
226+
* Skips the last N messages to preserve UX - user is likely reading the most recent response.
227+
*
228+
* @param keptNodes Array of kept message nodes to process
229+
*/
230+
export function runCompactor(keptNodes: HTMLElement[]): void {
231+
// Skip last N messages to preserve readability of latest responses
232+
const processableCount = Math.max(0, keptNodes.length - SKIP_LAST_N_MESSAGES);
233+
const nodesToProcess = keptNodes.slice(0, processableCount);
234+
235+
if (nodesToProcess.length === 0) {
236+
logDebug(`Compactor: No nodes to process (${keptNodes.length} kept, skipping last ${SKIP_LAST_N_MESSAGES})`);
237+
return;
238+
}
239+
240+
logDebug(`Compactor: Starting processing of ${nodesToProcess.length} nodes (skipping last ${SKIP_LAST_N_MESSAGES})`);
241+
242+
// Track if this is the first chunk (suppression starts on first actual DOM work)
243+
let isFirstChunk = true;
244+
245+
const runTask = (deadline: IdleDeadline, nodes: HTMLElement[], startIndex: number): void => {
246+
// Start suppression on first chunk (when we actually do DOM work, not before)
247+
if (isFirstChunk) {
248+
isFirstChunk = false;
249+
startMutationSuppression();
250+
} else {
251+
// Extend safety timeout for subsequent chunks
252+
extendSuppression();
253+
}
254+
255+
let totalProcessed = 0;
256+
let index = startIndex;
257+
let scheduledNext = false;
258+
259+
try {
260+
// Process nodes while we have idle time (at least 2ms per iteration)
261+
while (index < nodes.length && deadline.timeRemaining() > 2) {
262+
// Extend suppression every 4 iterations (not every iteration - cheaper)
263+
if ((index - startIndex) % 4 === 0) {
264+
extendSuppression();
265+
}
266+
267+
const node = nodes[index];
268+
if (node && node.isConnected) {
269+
totalProcessed += processCodeBlocks(node);
270+
}
271+
index++;
272+
}
273+
274+
if (totalProcessed > 0) {
275+
logInfo(`Compactor: Dehighlighted ${totalProcessed} code blocks`);
276+
}
277+
278+
// Continue if more nodes to process
279+
if (index < nodes.length) {
280+
logDebug(`Compactor: ${nodes.length - index} nodes remaining, scheduling next chunk`);
281+
scheduledNext = true;
282+
requestIdleCallback(
283+
(nextDeadline) => runTask(nextDeadline, nodes, index),
284+
{ timeout: 1000 }
285+
);
286+
} else {
287+
logDebug('Compactor: Processing complete');
288+
}
289+
} finally {
290+
// Stop suppression if we're not continuing (done or error)
291+
if (!scheduledNext) {
292+
stopMutationSuppression();
293+
}
294+
}
295+
};
296+
297+
// Start processing with 2 second timeout (will run even if no idle time)
298+
requestIdleCallback(
299+
(deadline) => runTask(deadline, nodesToProcess, 0),
300+
{ timeout: 2000 }
301+
);
302+
}
303+
304+
/**
305+
* Check if compactor should run based on settings.
306+
*
307+
* @param ultraLean Whether ultra lean mode is enabled
308+
* @returns true if compactor should run
309+
*/
310+
export function shouldRunCompactor(ultraLean: boolean): boolean {
311+
return ultraLean;
312+
}
313+
314+
/**
315+
* Reset processed nodes tracking.
316+
* Call this on navigation to new chat to allow re-processing.
317+
* Note: WeakSet automatically handles cleanup when nodes are removed,
318+
* but this is useful for explicit reset scenarios.
319+
*/
320+
export function resetCompactorState(): void {
321+
// WeakSet doesn't have a clear() method, but since it uses weak references,
322+
// old entries will be garbage collected when their DOM nodes are removed.
323+
// For explicit reset, we could create a new WeakSet, but it's not necessary
324+
// since the old nodes will be GC'd anyway.
325+
logDebug('Compactor: State reset (WeakSet will auto-clean)');
326+
}

0 commit comments

Comments
 (0)