Skip to content

Commit ab658ac

Browse files
waterdrop86claude
andcommitted
perf: skip initial PTY replay for fresh sessions and cache slash-command scans
Two cuts to the user-perceived "tile is empty" window after clicking Create Session. #1 — TerminalInstance's setup effect always issued pty:replay-since(0) on first mount and gated live writes until that IPC resolved, even for brand-new sessions whose broker ring buffer is empty by construction. The roundtrip cost an event-loop tick + IPC latency before xterm could paint the first byte. Mark freshly-created session IDs in a renderer- side Set inside handleCreateSession; consumeFresh on the first mount opens the live-write gate immediately and skips the replay block. Subsequent remounts (hide → dispose → reveal) consume false and go through the normal replay path, so the hide/reveal catch-up logic is unchanged. Skip is gated on isVisibleRef so a fresh-but-hidden mount falls through to the existing path that records the catch-up offset. #2 — The slash-command effect ran a fresh slash-commands:scan IPC on every tile mount, then awaited the result before populating the warning-on-typing set. For users with many command files, the scan is dozens of readFile calls. Add a renderer-side cwd+sessionType cache (slash-command-cache.ts): handleCreateSession warms it in parallel with PTY cold-start, so the tile-mount effect can read synchronously when the cache is hit. Concurrent scans dedupe via an inflight map; errors are not cached so a later attempt can retry. Behavioral note: the cache lives for the app lifetime and never invalidates, so a slash command file added at runtime won't be picked up until restart. The set is only used for typing-time warnings, so the regression is a phantom "not in mcode's known slash commands" message — the command itself still works. Tests: unit coverage for both utils (markFresh/consumeFresh idempotency and one-shot semantics; cache dedup, key-by-(type,cwd), error-not-cached). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d232983 commit ab658ac

6 files changed

Lines changed: 230 additions & 9 deletions

File tree

src/renderer/App.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { useChangesStore } from './stores/changes-store';
2222
import { useTodoStore } from './stores/todo-store';
2323
import { executeAppCommand } from './utils/app-commands';
2424
import { autoExpandInKanban } from './utils/session-actions';
25+
import { ensureSlashCommandsScan } from './utils/slash-command-cache';
26+
import { markFresh } from './utils/fresh-sessions';
2527
import TitleBar from './components/TitleBar';
2628
import { ErrorBoundary, ErrorFallback } from './components/shared/ErrorBoundary';
2729
import CreateTaskDialog from './components/shared/CreateTaskDialog';
@@ -32,6 +34,7 @@ import { useUpdateSubscriptions } from './hooks/useUpdateSubscriptions';
3234
import { useUpdateStore } from './stores/update-store';
3335
import UpdateBanner from './components/UpdateBanner';
3436
import { canSessionBeDefaultTaskTarget } from '@shared/session-capabilities';
37+
import { isAgentSessionType } from '@shared/session-agents';
3538
import type { CreateTaskInput, SessionCreateInput, SidebarTab } from '@shared/types';
3639

3740
function App(): React.JSX.Element {
@@ -167,7 +170,17 @@ function App(): React.JSX.Element {
167170

168171
const handleCreateSession = async (input: SessionCreateInput): Promise<void> => {
169172
try {
173+
// Warm the slash-command cache in parallel with PTY cold-start so the
174+
// tile-mount path can read it synchronously instead of issuing its own scan.
175+
const sessionType = input.sessionType ?? 'claude';
176+
if (isAgentSessionType(sessionType)) {
177+
ensureSlashCommandsScan(sessionType, input.cwd);
178+
}
179+
170180
const session = await window.mcode.sessions.create(input);
181+
// First mount of this session can skip the initial pty:replay-since(0)
182+
// round-trip — the broker ring buffer is empty by construction.
183+
markFresh(session.sessionId);
171184
addSession(session);
172185

173186
if (splitIntent) {

src/renderer/components/SessionTile/TerminalInstance.tsx

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ import {
2929
stripTerminalInputControlSequences,
3030
} from '../../utils/slash-command-validation';
3131
import { utf8ByteLength } from '../../utils/utf8-byte-length';
32+
import {
33+
ensureSlashCommandsScan,
34+
getSlashCommandsCached,
35+
} from '../../utils/slash-command-cache';
36+
import { consumeFresh } from '../../utils/fresh-sessions';
3237

3338
interface TerminalInstanceProps {
3439
sessionId: string;
@@ -114,16 +119,16 @@ function TerminalInstance({ sessionId, sessionType, scrollbackLines, isVisible =
114119
knownSlashCommandsRef.current = new Set();
115120
return;
116121
}
122+
const cached = getSlashCommandsCached(agent.sessionType, cwd);
123+
if (cached) {
124+
knownSlashCommandsRef.current = cached;
125+
return;
126+
}
117127
let stale = false;
118-
window.mcode.slashCommands.scan(agent.sessionType, cwd).then((commands) => {
119-
if (!stale) {
120-
knownSlashCommandsRef.current = new Set(commands.map((cmd) => cmd.name.toLowerCase()));
121-
}
122-
}).catch(() => {
123-
if (!stale) knownSlashCommandsRef.current = new Set();
128+
ensureSlashCommandsScan(agent.sessionType, cwd).then((set) => {
129+
if (!stale) knownSlashCommandsRef.current = set;
124130
});
125131
return () => { stale = true; };
126-
// eslint-disable-next-line react-hooks/exhaustive-deps -- sessionType is read via ref
127132
}, [cwd]);
128133

129134
// fit() while hidden would resize xterm to the 0×0 container, hand bash a
@@ -437,10 +442,21 @@ function TerminalInstance({ sessionId, sessionType, scrollbackLines, isVisible =
437442
// includes those bytes (broker appends synchronously per chunk), so we'd
438443
// double-write. The bridge loop below catches up bytes added between the
439444
// IPC send and response.
445+
//
446+
// Exception: a freshly-created session has an empty broker ring buffer by
447+
// construction, so the replay round-trip would just return zero bytes. We
448+
// skip it and open the gate immediately, removing one IPC roundtrip and
449+
// one event-loop tick from the user-perceived "tile is empty" window.
450+
const skipInitialReplay = consumeFresh(sessionId) && isVisibleRef.current;
440451
hiddenAtOffsetRef.current = null;
441-
dropLiveWritesRef.current = true;
442452
byteOffsetRef.current = 0;
443-
setTerminalLive(sessionId, false);
453+
if (skipInitialReplay) {
454+
dropLiveWritesRef.current = false;
455+
setTerminalLive(sessionId, true);
456+
} else {
457+
dropLiveWritesRef.current = true;
458+
setTerminalLive(sessionId, false);
459+
}
444460

445461
// Fit before replay: cursor-position / clear-screen escapes in the
446462
// buffered bytes are interpreted at the terminal's current cols/rows,
@@ -449,6 +465,7 @@ function TerminalInstance({ sessionId, sessionType, scrollbackLines, isVisible =
449465
let cancelledInitial = false;
450466
const initialFitTimer = window.setTimeout(() => {
451467
safeFit();
468+
if (skipInitialReplay) return;
452469
(async () => {
453470
try {
454471
const r = await window.mcode.pty.getReplaySince(sessionId, 0);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Session IDs whose first TerminalInstance mount can skip the initial
2+
// pty:replay-since(0) round-trip. Marked by handleCreateSession (the spawn
3+
// is brand-new, so the broker ring buffer is empty); consumed by the
4+
// TerminalInstance setup effect on the *first* mount only — a later
5+
// hide → dispose → reveal remount goes through the normal replay path.
6+
7+
const fresh = new Set<string>();
8+
9+
export function markFresh(sessionId: string): void {
10+
fresh.add(sessionId);
11+
}
12+
13+
export function consumeFresh(sessionId: string): boolean {
14+
return fresh.delete(sessionId);
15+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { SlashCommandEntry } from '@shared/types';
2+
import type { AgentSessionType } from '@shared/session-agents';
3+
4+
type Key = string;
5+
6+
const cache = new Map<Key, Set<string>>();
7+
const inflight = new Map<Key, Promise<Set<string>>>();
8+
9+
function key(sessionType: AgentSessionType, cwd: string): Key {
10+
return `${sessionType}|${cwd}`;
11+
}
12+
13+
function toSet(commands: SlashCommandEntry[]): Set<string> {
14+
return new Set(commands.map((cmd) => cmd.name.toLowerCase()));
15+
}
16+
17+
export function getSlashCommandsCached(
18+
sessionType: AgentSessionType,
19+
cwd: string,
20+
): Set<string> | undefined {
21+
return cache.get(key(sessionType, cwd));
22+
}
23+
24+
// Kicks off (or joins) a scan and resolves with the cached set.
25+
// Errors aren't cached so a later attempt can retry; the resolved value is an empty set.
26+
export function ensureSlashCommandsScan(
27+
sessionType: AgentSessionType,
28+
cwd: string,
29+
): Promise<Set<string>> {
30+
const k = key(sessionType, cwd);
31+
const cached = cache.get(k);
32+
if (cached) return Promise.resolve(cached);
33+
const existing = inflight.get(k);
34+
if (existing) return existing;
35+
const p = window.mcode.slashCommands
36+
.scan(sessionType, cwd)
37+
.then((commands) => {
38+
const set = toSet(commands);
39+
cache.set(k, set);
40+
inflight.delete(k);
41+
return set;
42+
})
43+
.catch(() => {
44+
inflight.delete(k);
45+
return new Set<string>();
46+
});
47+
inflight.set(k, p);
48+
return p;
49+
}
50+
51+
export function _resetSlashCommandCacheForTests(): void {
52+
cache.clear();
53+
inflight.clear();
54+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { markFresh, consumeFresh } from '../../../../src/renderer/utils/fresh-sessions';
3+
4+
describe('fresh-sessions', () => {
5+
it('returns true on the first consume after markFresh, false thereafter', () => {
6+
const id = 'session-' + Math.random();
7+
markFresh(id);
8+
expect(consumeFresh(id)).toBe(true);
9+
expect(consumeFresh(id)).toBe(false);
10+
});
11+
12+
it('returns false for an unmarked session', () => {
13+
expect(consumeFresh('session-' + Math.random())).toBe(false);
14+
});
15+
16+
it('treats each session id independently', () => {
17+
const a = 'a-' + Math.random();
18+
const b = 'b-' + Math.random();
19+
markFresh(a);
20+
markFresh(b);
21+
expect(consumeFresh(a)).toBe(true);
22+
expect(consumeFresh(b)).toBe(true);
23+
expect(consumeFresh(a)).toBe(false);
24+
expect(consumeFresh(b)).toBe(false);
25+
});
26+
27+
it('marking twice is idempotent', () => {
28+
const id = 'dup-' + Math.random();
29+
markFresh(id);
30+
markFresh(id);
31+
expect(consumeFresh(id)).toBe(true);
32+
expect(consumeFresh(id)).toBe(false);
33+
});
34+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// @vitest-environment happy-dom
2+
import { describe, it, expect, beforeEach, vi } from 'vitest';
3+
import {
4+
ensureSlashCommandsScan,
5+
getSlashCommandsCached,
6+
_resetSlashCommandCacheForTests,
7+
} from '../../../../src/renderer/utils/slash-command-cache';
8+
import type { SlashCommandEntry } from '../../../../src/shared/types';
9+
10+
interface MockGlobal {
11+
mcode: {
12+
slashCommands: { scan: ReturnType<typeof vi.fn> };
13+
};
14+
}
15+
16+
function setScanResult(value: SlashCommandEntry[] | Promise<SlashCommandEntry[]>): ReturnType<typeof vi.fn> {
17+
const scan = vi.fn(() => (value instanceof Promise ? value : Promise.resolve(value)));
18+
(window as unknown as MockGlobal).mcode = {
19+
slashCommands: { scan },
20+
};
21+
return scan;
22+
}
23+
24+
function entry(name: string): SlashCommandEntry {
25+
return { name, description: '', source: 'user' };
26+
}
27+
28+
describe('slash-command-cache', () => {
29+
beforeEach(() => {
30+
_resetSlashCommandCacheForTests();
31+
});
32+
33+
it('returns undefined from getSlashCommandsCached before any scan', () => {
34+
setScanResult([]);
35+
expect(getSlashCommandsCached('claude', '/repo')).toBeUndefined();
36+
});
37+
38+
it('caches the scan result and lowercases names', async () => {
39+
setScanResult([entry('Foo'), entry('BAR')]);
40+
const set = await ensureSlashCommandsScan('claude', '/repo');
41+
expect(set).toEqual(new Set(['foo', 'bar']));
42+
expect(getSlashCommandsCached('claude', '/repo')).toBe(set);
43+
});
44+
45+
it('dedupes concurrent scans for the same key into a single IPC call', async () => {
46+
let resolveScan: (cmds: SlashCommandEntry[]) => void = () => {};
47+
const pending = new Promise<SlashCommandEntry[]>((res) => { resolveScan = res; });
48+
const scan = setScanResult(pending);
49+
50+
const p1 = ensureSlashCommandsScan('claude', '/repo');
51+
const p2 = ensureSlashCommandsScan('claude', '/repo');
52+
expect(scan).toHaveBeenCalledTimes(1);
53+
54+
resolveScan([entry('alpha')]);
55+
const [s1, s2] = await Promise.all([p1, p2]);
56+
expect(s1).toBe(s2);
57+
expect(s1).toEqual(new Set(['alpha']));
58+
});
59+
60+
it('keys cache by sessionType and cwd', async () => {
61+
const scan = setScanResult([entry('hi')]);
62+
await ensureSlashCommandsScan('claude', '/a');
63+
await ensureSlashCommandsScan('claude', '/b');
64+
await ensureSlashCommandsScan('codex', '/a');
65+
expect(scan).toHaveBeenCalledTimes(3);
66+
67+
// Subsequent calls hit the cache.
68+
await ensureSlashCommandsScan('claude', '/a');
69+
expect(scan).toHaveBeenCalledTimes(3);
70+
});
71+
72+
it('does not cache errors so a later attempt can retry', async () => {
73+
const scan = vi.fn()
74+
.mockRejectedValueOnce(new Error('boom'))
75+
.mockResolvedValueOnce([entry('ok')]);
76+
(window as unknown as MockGlobal).mcode = {
77+
slashCommands: { scan },
78+
};
79+
80+
const first = await ensureSlashCommandsScan('claude', '/repo');
81+
expect(first).toEqual(new Set());
82+
expect(getSlashCommandsCached('claude', '/repo')).toBeUndefined();
83+
84+
const second = await ensureSlashCommandsScan('claude', '/repo');
85+
expect(second).toEqual(new Set(['ok']));
86+
expect(scan).toHaveBeenCalledTimes(2);
87+
});
88+
});

0 commit comments

Comments
 (0)