The Cmd-K Input Was Fine. The Search Architecture Wasn't.

A reproducible lab for profiling Codex desktop Cmd-K, separating action, metadata, and content search, adding bounded typo recovery, and safely rebuilding a version-locked Electron ASAR.

The Cmd-K input was not slow. A search pipeline attached synchronous string work, a fixed delay, raw-file scanning, and competing result publication to every character.

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 itemValue
macOS app26.818.22352, build 6872
Bundled Codex CLI0.148.0-alpha.21
Node.js24.0.2; the kit requires 22.5+
Active chats37
Rollout source445.9 MiB
Searchable message text7.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.

capture-environment.sh
1node --version
2plutil -extract CFBundleShortVersionString raw \
3 /Applications/ChatGPT.app/Contents/Info.plist
4plutil -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.

profile-the-search-path.sh
1# Warm, sequential synthetic misses
2node 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
7node 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
13node 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 workloadMedianSlow end
Renderer metadata work per key0.8702 ms1.3355 ms p95
Warm sequential backend search55.01 ms63.32 ms max
20 requests at 10 ms cadence333.06 ms436.23 ms max
Renderer delay before content search200 msfixed

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.

inspect-the-renderer.sh
1mkdir -p ./asar-audit
2cp -p /Applications/ChatGPT.app/Contents/Resources/app.asar \
3 ./asar-audit/app.asar
4
5node inspect-asar.mjs ./asar-audit/app.asar \
6 --out=./asar-audit/extracted
7
8rg -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 markerObserved role
C7lCombines immediate and asynchronous results
IBaRebuilds loaded thread records
CV(query, 200)Applies the fixed 200 ms delay
command-menu-thread-searchKeys the asynchronous thread query
R7lRenders the loading row
F7tReturns 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.

observed-build-6872-pipeline.txt
1on 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.

CorpusData structureBudgetUpdate rule
ActionsImmutable array of labels and keywordsSame frameApp release or feature state
Chat metadataPacked array of bounded normalized documentsSame frameThread-list revision
Chat contentPersistent FTS5 indexAsync enrichmentMessage 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.

thread-search-document.ts
1type 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.

bounded-levenshtein.mjs
1function actionTypoThreshold(query) {
2 if (query.length < 4) return 0;
3 return Math.min(2, Math.floor(query.length / 4));
4}
5
6function 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.

immediate-query.mjs
1query(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.

thread-message-search.sql
1CREATE 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
8SELECT thread_id,
9 snippet(thread_message_search, 2, '', '', ' … ', 24) AS snippet,
10 bm25(thread_message_search) AS rank
11FROM thread_message_search
12WHERE thread_message_search MATCH ?
13ORDER BY rank
14LIMIT 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.

latest-only-coordinator.txt
1raw 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.

build-and-install.sh
1shasum -a 256 ./asar-audit/app.asar
2
3node 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
10sh 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 checkVerified result
Source archive SHA-256530f670f…d1d42
Patched archive SHA-25646d24d95…108d
Packed entries re-verified8,160
Patched entry growth400 bytes
Behavior harnessnew 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 caseExpected result
Type while chats are loadingActions remain searchable and focused
Type new chatDirect New chat action ranks first
Type new cahtTypo fallback resolves to New chat
Type an unmatched phraseSelected New chat row appears immediately
Type ten characters rapidlyInput paints each frame; stale results never publish
Wait for content resultsRows 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.

rollback.sh
1# Quit the app first
2cp -p ./chatgpt-cmdk-backup-6872/app.asar.original \
3 /Applications/ChatGPT.app/Contents/Resources/app.asar
4cp -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.