This is the lab notebook for reproducing that diagnosis, rebuilding the search path from first principles, and installing one narrow local test patch.
1. Scope and exact environment
The renderer is distributed inside an Electron ASAR, not as a documented source tree. The renderer names below are reverse-engineered build artifacts. They can change on every release.
OpenAI’s developer site does not document these palette internals. The backend search implementation is public insearch_threads.rs.
| Reference item | Value |
|---|---|
| macOS app | 26.818.22352, build 6872 |
| Bundled Codex CLI | 0.148.0-alpha.21 |
| Node.js | 24.0.2; the kit requires 22.5+ |
| Active chats | 37 |
| Rollout source | 445.9 MiB |
| Searchable message text | 7.8 MiB across 2,501 messages |
Capture versions before comparing timings. An app update changes the renderer, bundled backend, archive hash, and sometimes the local data shape.
| 1 | node --version |
| 2 | plutil -extract CFBundleShortVersionString raw \ |
| 3 | /Applications/ChatGPT.app/Contents/Info.plist |
| 4 | plutil -extract CFBundleVersion raw \ |
| 5 | /Applications/ChatGPT.app/Contents/Info.plist |
| 6 | /Applications/ChatGPT.app/Contents/Resources/codex --version |
The database and rollout files remain local. The scripts print aggregate counts and timings, not conversation text. No database, rollout, executable, or ASAR is included in the download.
2. Reproduce and quantify the lag
Open Cmd-K and type a phrase quickly. Use a synthetic miss for backend timing so result rendering and real content do not dominate the measurement. Repeat after one warm-up query.
Measure two workloads. Sequential requests establish service time. Requests started 10 ms apart model a user typing faster than search can finish.
| 1 | # Warm, sequential synthetic misses |
| 2 | node thread-search-load-profile.mjs \ |
| 3 | --mode=sequential --runs=12 --interval=0 \ |
| 4 | --prefix=__cmdk_seq_ |
| 5 | |
| 6 | # Burst pressure: 20 searches started 10 ms apart |
| 7 | node thread-search-load-profile.mjs \ |
| 8 | --mode=overlap --runs=20 --interval=10 \ |
| 9 | --prefix=__cmdk_overlap_ \ |
| 10 | --sample=/tmp/cmdk-overlap.sample.txt |
| 11 | |
| 12 | # Compare the old-shaped hot path with the proposed indexes |
| 13 | node profile.mjs --state="$HOME/.codex/state_5.sqlite" --runs=200 |
The backend profiler speaks the same newline-delimited app-server protocol as the desktop client: initialize, send thread/search, and time each response by request ID.
Passing --sample starts macOSsample against the backend process. This connects request latency to native stacks instead of relying on wall time alone.
| Build-6872 workload | Median | Slow end |
|---|---|---|
| Renderer metadata work per key | 0.8702 ms | 1.3355 ms p95 |
| Warm sequential backend search | 55.01 ms | 63.32 ms max |
| 20 requests at 10 ms cadence | 333.06 ms | 436.23 ms max |
| Renderer delay before content search | 200 ms | fixed |
A warm content result therefore could not react in less than about 255 ms. A burst could cross 400 ms after the fixed delay. The controlled input was waiting behind work it did not need.
3. Locate the packaged search path
Do not guess the renderer asset name. Parse the ASAR header, walk packed entries, and locate semantic markers. The included inspector extracts only matching JavaScript entries.
| 1 | mkdir -p ./asar-audit |
| 2 | cp -p /Applications/ChatGPT.app/Contents/Resources/app.asar \ |
| 3 | ./asar-audit/app.asar |
| 4 | |
| 5 | node inspect-asar.mjs ./asar-audit/app.asar \ |
| 6 | --out=./asar-audit/extracted |
| 7 | |
| 8 | rg -n \ |
| 9 | "command-menu-thread-search|search-threads-for-host|Loading chats|No matches" \ |
| 10 | ./asar-audit/extracted |
On build 6872, the relevant asset waswebview/assets/app-initial-2HRzhJVF.js. Formatting a copy makes control flow readable without changing byte offsets.
| Build-6872 marker | Observed role |
|---|---|
C7l | Combines immediate and asynchronous results |
IBa | Rebuilds loaded thread records |
CV(query, 200) | Applies the fixed 200 ms delay |
command-menu-thread-search | Keys the asynchronous thread query |
R7l | Renders the loading row |
F7t | Returns the complete title string |
The public backend confirms the other half: local thread search iterates rollout candidates and derives a snippet from matching rollout content. That is appropriate as a fallback, not as per-prefix hot-path work.
4. Profile one keystroke and its overlap
Trace from the raw input handler, not from the loading spinner. For every function, record whether it runs synchronously, allocates, starts I/O, or can publish after a newer query exists.
| 1 | on input |
| 2 | rebuild loaded thread records |
| 3 | score message-sized "title" strings synchronously |
| 4 | publish immediate rows |
| 5 | wait 200 ms |
| 6 | start an independent thread/search request |
| 7 | reserve one result slot for "Loading chats…" |
| 8 | publish whichever request resolves |
Across 37 active chats, the field named titleheld 601,876 characters. The average was 16,267; the largest was 56,145. These were message-sized strings masquerading as metadata.
Every key rebuilt thread records and normalized or scored those strings. The list then removed one real result while content search ran, so loading also changed list height and keyboard position.
The async query function did not receive a cancellation signal. A debounce delayed starts but did not cancel work already running. Older generations could still compete to publish.
5. Model three different search problems
Cmd-K searches actions, chat metadata, and chat content. Their scale, freshness, typo policy, and latency budget differ. One algorithm should not serve all three.
| Corpus | Data structure | Budget | Update rule |
|---|---|---|---|
| Actions | Immutable array of labels and keywords | Same frame | App release or feature state |
| Chat metadata | Packed array of bounded normalized documents | Same frame | Thread-list revision |
| Chat content | Persistent FTS5 index | Async enrichment | Message append |
Actions must remain mounted while chats load and while content search runs. Async state may replace the chat group, but it must never own the action group.
Stable row keys preserve focus. Useaction:<id> for actions, thread IDs for chats, and one constant new-chatkey for the fallback.
6. Build the immediate indexes
Rebuild metadata only when source threads change. Select the first non-empty display line, cap it at 512 characters, normalize once, and freeze the resulting documents.
| 1 | type ThreadSearchDocument = Readonly<{ |
| 2 | id: string; |
| 3 | updatedAt: number; |
| 4 | displayTitle: string; // first non-empty line, max 512 chars |
| 5 | title: string; // normalized once |
| 6 | branch: string; // normalized once |
| 7 | project: string; // normalized once |
| 8 | cwd: string; // normalized once |
| 9 | }>; |
A trie is unnecessary at tens or hundreds of chats. A packed array has good locality, trivial atomic replacement, and no incremental bookkeeping. Long text remains searchable in FTS.
Score exact, prefix, word-start, substring, then subsequence matches. Keep a sorted top-seven buffer while scanning instead of sorting every candidate. The bound makes memory and comparison cost predictable.
On the reference data, index construction took 1.4005 ms. Per-key search measured 0.0403 ms median and 0.0612 ms p95: 21.8× faster at p95 than the shipped-shaped benchmark.
7. Add bounded Levenshtein for actions only
Run direct action matching first. Only a miss enters edit-distance search. Compare the small set of action labels and keywords, never chat titles or message content.
| 1 | function actionTypoThreshold(query) { |
| 2 | if (query.length < 4) return 0; |
| 3 | return Math.min(2, Math.floor(query.length / 4)); |
| 4 | } |
| 5 | |
| 6 | function boundedLevenshtein(left, right, maxDistance) { |
| 7 | if (left === right) return 0; |
| 8 | if (Math.abs(left.length - right.length) > maxDistance) { |
| 9 | return maxDistance + 1; |
| 10 | } |
| 11 | if (left.length > right.length) { |
| 12 | return boundedLevenshtein(right, left, maxDistance); |
| 13 | } |
| 14 | |
| 15 | let previous = Array.from({ length: left.length + 1 }, (_, i) => i); |
| 16 | for (let r = 1; r <= right.length; r += 1) { |
| 17 | const current = [r]; |
| 18 | let rowMinimum = current[0]; |
| 19 | for (let l = 1; l <= left.length; l += 1) { |
| 20 | const substitution = left[l - 1] === right[r - 1] ? 0 : 1; |
| 21 | current[l] = Math.min( |
| 22 | current[l - 1] + 1, |
| 23 | previous[l] + 1, |
| 24 | previous[l - 1] + substitution, |
| 25 | ); |
| 26 | rowMinimum = Math.min(rowMinimum, current[l]); |
| 27 | } |
| 28 | if (rowMinimum > maxDistance) return maxDistance + 1; |
| 29 | previous = current; |
| 30 | } |
| 31 | return previous[left.length]; |
| 32 | } |
The threshold is zero below four characters and grows to at most two edits. The length-difference check rejects impossible candidates before allocating rows. The row minimum exits once the bound is unreachable.
This makes new caht matchNew chat. Direct matches still rank first, and short inputs do not turn the palette into a field of unrelated fuzzy results.
8. Make an immediate miss useful
If no action or metadata result matches, publishNew chat: “the phrase” synchronously and keep it selected. Enter must work without waiting for historical content.
| 1 | query(rawQuery) { |
| 2 | const query = rawQuery.trim(); |
| 3 | const generation = ++this.#generation; |
| 4 | const immediate = this.#immediateIndex.query(query, this.#limit); |
| 5 | const actions = this.#actionIndex.query(query, this.#limit); |
| 6 | const fallback = actions.length === 0 && immediate.results.length === 0 && query |
| 7 | ? { key: "new-chat", kind: "new-chat", query } |
| 8 | : null; |
| 9 | |
| 10 | this.#onPublish({ |
| 11 | generation, |
| 12 | phase: "immediate", |
| 13 | query, |
| 14 | rows: [...actions, ...(fallback ? [fallback] : immediate.results)], |
| 15 | }); |
| 16 | this.#pending = { actions, fallback, generation, |
| 17 | immediate: immediate.results, query }; |
| 18 | } |
Older content hits may enrich the list below that row. They do not steal selection. The fallback is an explicit product rule, not a side effect of an empty async response.
9. Index content and publish latest-only
The active rollouts occupied 445.9 MiB, but searchable user and assistant text was 7.8 MiB. Tool results, diffs, and protocol records do not belong in a chat-search corpus.
Insert searchable messages into FTS5 as they materialize. Query a bounded candidate set, deduplicate by thread ID, and merge with immediate rows using stable keys.
| 1 | CREATE VIRTUAL TABLE thread_message_search USING fts5( |
| 2 | thread_id UNINDEXED, |
| 3 | rollout_ordinal UNINDEXED, |
| 4 | body, |
| 5 | tokenize = 'trigram case_sensitive 0' |
| 6 | ); |
| 7 | |
| 8 | SELECT thread_id, |
| 9 | snippet(thread_message_search, 2, '', '', ' … ', 24) AS snippet, |
| 10 | bm25(thread_message_search) AS rank |
| 11 | FROM thread_message_search |
| 12 | WHERE thread_message_search MATCH ? |
| 13 | ORDER BY rank |
| 14 | LIMIT 64; |
The reference one-time backfill took 2.893 seconds. Queries measured 5.3466 ms median and 27.5443 ms p95. A background migration can retain raw rollout scanning as a temporary fallback.
Debounce is a scheduling policy, not a correctness policy. Use a generation counter and a serial coalescing coordinator: one content request in flight, one pending request, and only the latest generation may publish.
| 1 | raw input, generation N |
| 2 | ├─ search actions synchronously → publish this frame |
| 3 | ├─ search metadata synchronously → publish this frame |
| 4 | └─ replace the pending content request with generation N |
| 5 | └─ if idle, start one request |
| 6 | ├─ discard result unless N is still current |
| 7 | └─ run the newest pending request, if any |
This caps concurrency and pending memory regardless of typing speed. It also avoids relying on transport cancellation, which may not stop backend work after a request has started.
10. Rebuild and install the version-locked patch
The downloadable patch installs only bounded action typo recovery. It does not install the metadata index, FTS index, coordinator, or automatic new-chat fallback.
The patcher accepts one exact minified function in one exact build. It refuses unknown input, validates behavior first, rebuilds the archive, then re-verifies every packed entry.
| 1 | shasum -a 256 ./asar-audit/app.asar |
| 2 | |
| 3 | node patch-cmdk-action-filter-asar.mjs \ |
| 4 | ./asar-audit/app.asar \ |
| 5 | ./asar-audit/app.patched.asar \ |
| 6 | ./asar-audit/app-initial.patched.js \ |
| 7 | ./asar-audit/patch-manifest.json |
| 8 | |
| 9 | # Unsupported: runs only against build 6872 and its exact source hash |
| 10 | sh install-build-6872.sh \ |
| 11 | /Applications/ChatGPT.app \ |
| 12 | "$PWD/chatgpt-cmdk-backup-6872" |
Changing the target entry added 400 bytes. Every later packed offset moved. A byte replacement would corrupt the archive, so the script rebuilds offsets, entry hashes, block hashes, and the serialized header.
Electron’sASAR integrity documentationexplains the per-entry data. On macOS, the header hash also lives underElectronAsarIntegrity in the plist.
The installer quits if the app is running. It also refuses every build except 6872, refuses any source ASAR except the verified SHA-256, and refuses to overwrite an existing backup directory.
| Build-6872 rebuild check | Verified result |
|---|---|
| Source archive SHA-256 | 530f670f…d1d42 |
| Patched archive SHA-256 | 46d24d95…108d |
| Packed entries re-verified | 8,160 |
| Patched entry growth | 400 bytes |
| Behavior harness | new caht → New chat |
11. Validate behavior, performance, and archive integrity
Run npm test first. The six tests cover bounded titles, persistent actions, action typos, new-chat fallback, latest-only publication, and stable thread keys.
| UI case | Expected result |
|---|---|
| Type while chats are loading | Actions remain searchable and focused |
Type new chat | Direct New chat action ranks first |
Type new caht | Typo fallback resolves to New chat |
| Type an unmatched phrase | Selected New chat row appears immediately |
| Type ten characters rapidly | Input paints each frame; stale results never publish |
| Wait for content results | Rows merge by key; selection does not jump |
Re-run both profiles after implementation. Log input-to-immediate-publish, content latency, maximum concurrent searches, maximum pending searches, and stale generations published.
The prototype recorded one maximum concurrent content query, one pending query, and zero stale publications. It passed all six behavior tests.
I re-ran the portable lab after the updater installed build 6962. With 50 active chats, bounded metadata search was 23.2× faster at p95. FTS measured 6.5898 ms median and 31.9767 ms p95.
The exact installer correctly refused build 6962. That refusal is part of validation: a local binary patch that silently accepts a new build is not a safe experiment.
12. Roll back and separate installed from proposed
The installer backs up the original ASAR, plist, executable, and signing resources. It changes only app.asarand the ASAR header hash in Info.plist.
| 1 | # Quit the app first |
| 2 | cp -p ./chatgpt-cmdk-backup-6872/app.asar.original \ |
| 3 | /Applications/ChatGPT.app/Contents/Resources/app.asar |
| 4 | cp -p ./chatgpt-cmdk-backup-6872/Info.plist.original \ |
| 5 | /Applications/ChatGPT.app/Contents/Info.plist |
Restart after restoration. A normal app update may also replace the patch. In this case, the updater replaced build 6872 with build 6962 the next day, exactly as expected.
I reported the measurements and design inthe performance issueandthe new-chat behavior issue. The kit makes the report independently repeatable.
The broader lesson is structural: latency budgets should shape indexes and ownership. The input owns raw text. Immediate indexes own the current frame. Async content search may enrich that frame, but never hold it hostage.