Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
97e9660
test(terminal-core): pin today's ended-region tracker lifetime (desig…
tamtranthien Aug 15, 2026
648df33
refactor(terminal-core): split container listeners into containerDisp…
tamtranthien Aug 15, 2026
1b6fe09
feat(terminal-core): add setSurfaceDisplayed and gate geometry on it …
tamtranthien Aug 15, 2026
23ad429
fix(terminal-core): stamp the convergence resize on an armed relocati…
tamtranthien Aug 15, 2026
9cc0939
feat(terminal-core): gate suggest emission on the container's chrome …
tamtranthien Aug 15, 2026
0ffc049
feat(terminal-core): move the ended-region rail layer with the termin…
tamtranthien Aug 15, 2026
bd68ad9
test(terminal-core): make the T11 tripwire count gate reads, not prose
tamtranthien Aug 15, 2026
0deac72
feat(terminal-core): add relocateTo — move the xterm element between …
tamtranthien Aug 15, 2026
e466525
feat(terminal-core): relocate the rail, restore focus and flip the su…
tamtranthien Aug 15, 2026
6fa50ff
feat(terminal-core): move surface eligibility inside relocateTo so ab…
tamtranthien Aug 15, 2026
6b6014b
test(terminal-core): pin the geometry and backend-resize contract acr…
tamtranthien Aug 15, 2026
1e34e34
feat(renderer): add the surface-host registry (design 012 §4.1)
tamtranthien Aug 15, 2026
49b7280
feat(renderer): add useSurfaceRelocation with the engineGeneration de…
tamtranthien Aug 15, 2026
4ffb1c3
feat(renderer): relocate the terminal surface between its pane and a …
tamtranthien Aug 15, 2026
22a8abc
fix(renderer): give every node carrying a terminal id its own backgro…
tamtranthien Aug 15, 2026
e35013a
docs(renderer): fix two stale line refs in the capture comments (revi…
tamtranthien Aug 15, 2026
fba69f8
fix(terminal-core): setFontSize must arm the flush-bearing fit, not a…
tamtranthien Aug 15, 2026
06c9b59
fix(terminal-core): arm the settle fit on the return leg, not just th…
tamtranthien Aug 15, 2026
63aac97
fix(terminal-core): mount() evicts a foreign surface before attaching…
tamtranthien Aug 15, 2026
542ceaa
fix(terminal-core): teardown must honour the park it was never allowe…
tamtranthien Aug 15, 2026
6b7ab3c
test: keep .claude worktrees out of the root jest module map
tamtranthien Aug 15, 2026
2f02db4
test(terminal-core): prove the dropped teardown resize is recovered, …
tamtranthien Aug 15, 2026
fc69b68
docs(comment): the unmount call site's force is conditional now (revi…
tamtranthien Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,18 @@ module.exports = {
// Coverage reporters
coverageReporters: ['text', 'lcov', 'html'],

// Git worktrees live in `<project>/.claude/` by project convention, which puts a
// second full copy of the repo INSIDE the repo. Without this, jest builds one
// module map across both copies, every package.json name collides, and suites fail
// in the MAIN tree as well — reporting failures that have nothing to do with the
// code under test. modulePathIgnorePatterns (not just testPathIgnorePatterns) is
// the one that keeps the copy out of the module map.
modulePathIgnorePatterns: ['<rootDir>/.claude/'],

// Ignore patterns
testPathIgnorePatterns: [
'/node_modules/',
'/\\.claude/', // worktrees — see modulePathIgnorePatterns above
'/dist/',
'/build/',
'/tests/e2e/',
Expand Down
928 changes: 760 additions & 168 deletions packages/terminal-core/src/TerminalEngine.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/terminal-core/src/__mocks__/xterm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ export class Terminal {
// Real xterm appends its render element to the container; emulate enough that
// the reattach path (`cached.terminal.element`) sees a live node.
const el = (typeof document !== 'undefined' ? document.createElement('div') : ({} as HTMLElement));
// Real xterm classes its render element `terminal xterm`. Carried here so a
// test can tell a terminal surface apart from the pane's other children.
if (typeof (el as HTMLElement).className === 'string') el.className = 'terminal xterm';
this.element = el;
if (container && typeof (container as HTMLElement).appendChild === 'function') {
container.appendChild(el);
Expand Down
6 changes: 6 additions & 0 deletions packages/terminal-core/src/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function fakeEntry() {
hydrating: false,
pendingOutput: [],
disposables: [() => disposed.push('d1')],
containerDisposables: [],
dataDisposable: { dispose: () => disposed.push('data') },
exitDisposable: { dispose: () => disposed.push('exit') },
} as unknown as TerminalCacheEntry;
Expand Down Expand Up @@ -70,6 +71,7 @@ test('cleanupTerminalCache works when R1 subscriptions are absent', () => {
hydrating: false,
pendingOutput: [],
disposables: [() => disposed.push('d1')],
containerDisposables: [],
} as unknown as TerminalCacheEntry;
terminalCache.set('t2', entry);

Expand All @@ -93,6 +95,7 @@ test('cleanupTerminalCache: a throwing webglAddon.dispose() still tears down the
hydrating: false,
pendingOutput: [],
disposables: [() => disposed.push('d1')],
containerDisposables: [],
} as unknown as TerminalCacheEntry;
terminalCache.set('t3', entry);

Expand All @@ -113,6 +116,7 @@ test('cleanupTerminalCache disposes protocolDisposables (backlog 003)', () => {
hydrating: false,
pendingOutput: [],
disposables: [() => disposed.push('d1')],
containerDisposables: [],
protocolDisposables: [() => disposed.push('proto1'), () => disposed.push('proto2')],
} as unknown as TerminalCacheEntry;
terminalCache.set('t-proto', entry);
Expand All @@ -133,6 +137,7 @@ test('cleanupTerminalCache: a throwing protocolDisposable still lets the rest te
hydrating: false,
pendingOutput: [],
disposables: [],
containerDisposables: [],
protocolDisposables: [
() => {
throw new Error('boom');
Expand Down Expand Up @@ -165,6 +170,7 @@ test('cleanupTerminalCache: a throwing local disposable still lets data/exit sub
},
() => disposed.push('d2'),
],
containerDisposables: [],
dataDisposable: { dispose: () => disposed.push('data') },
exitDisposable: { dispose: () => disposed.push('exit') },
} as unknown as TerminalCacheEntry;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* engine.container-disposables.test.ts
*
* design/012 D6 + §5.5 + §5.8 — §13 T3, T4, T13 (the mount()-side halves; the
* relocateTo halves land in Task 7).
*
* The split: `disposables` holds everything bound to something that SURVIVES a
* relocation (the same `boundTerm`, the reused SearchAddon, one-shot mount-time
* timers). `containerDisposables` holds exactly the four listeners bound to the
* `container` ARGUMENT: click-to-focus (TerminalEngine.ts:1785-1792),
* capture-phase zoom keydown (:1804-1831), Ctrl/Cmd+F (:1837-1849) and
* modifier+wheel (:1854-1864). The ResizeObserver is deliberately NOT one of
* them (D7) — it has exactly one owner, `this.resizeObserver`.
*
* Why the split matters: because the xterm subscriptions are never torn down by
* relocation, `boundTerm.onResize` (:1101-1120) stays live across the whole
* operation. That is what retires the orphaned-resize class documented by
* engine.remount-resize.test.ts:12-20 — a bug that exists precisely because
* mount() disposes onResize at :742 BEFORE fitting at :749 and re-wires it ~350
* lines later at :1101.
*/

import { TerminalEngine } from '../TerminalEngine';
import { terminalCache } from '../cache';
import type { TerminalBridge, Disposable } from '../types';

function makeFakeBridge(): TerminalBridge {
const noopDisposable: Disposable = { dispose() {} };
return {
onData: () => noopDisposable,
onExit: () => noopDisposable,
write: () => {},
resize: () => {},
};
}

function makeContainer(width = 800, height = 600): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'terminal-display-wrapper';
const el = document.createElement('div');
el.className = 'terminal-display';
Object.defineProperty(el, 'offsetWidth', { value: width, configurable: true });
Object.defineProperty(el, 'offsetHeight', { value: height, configurable: true });
Object.defineProperty(el, 'offsetParent', { value: document.body, configurable: true });
wrapper.appendChild(el);
document.body.appendChild(wrapper);
return el;
}

let prevRO: unknown;

beforeEach(() => {
terminalCache.clear();
prevRO = (globalThis as any).ResizeObserver;
(globalThis as any).ResizeObserver = class {
observe(): void {}
disconnect(): void {}
unobserve(): void {}
};
});

afterEach(() => {
terminalCache.clear();
document.body.innerHTML = '';
if (prevRO === undefined) delete (globalThis as any).ResizeObserver;
else (globalThis as any).ResizeObserver = prevRO;
});

describe('design/012 D6 — the container/local disposables split', () => {
// §13 T13, first clause. The array must actually be POPULATED — an empty
// `containerDisposables` is the exact defect review 094 B6 found in rev 4.
it('puts the four container listeners in containerDisposables and nowhere else', () => {
const engine = new TerminalEngine(makeFakeBridge(), { cacheKey: 'cd-membership' });
const localsBefore = (engine as any).disposables.length;
engine.mount(makeContainer());

expect((engine as any).containerDisposables.length).toBe(4);
// The entry mirrors the engine's LIVE array by reference, not by copy —
// exactly how `disposables: this.disposables` already works (:1753).
expect(terminalCache.get('cd-membership')!.containerDisposables)
.toBe((engine as any).containerDisposables);
// And they are not ALSO in `disposables`, or relocation would tear down the
// xterm subscriptions with them.
expect((engine as any).disposables.length).toBeGreaterThan(localsBefore);
expect((engine as any).disposables)
.not.toEqual(expect.arrayContaining((engine as any).containerDisposables));
});

// §13 T3, mount() half: with paneChrome:true (what mount() always passes)
// every listener behaves byte-for-byte as it does today.
it('wires all four listeners on the container mount() was given', () => {
let openSearchCalls = 0;
let zoomCalls = 0;
const engine = new TerminalEngine(makeFakeBridge(), {
cacheKey: 'cd-wired',
isMac: false,
onOpenSearch: () => { openSearchCalls += 1; },
onZoom: () => { zoomCalls += 1; },
});
const container = makeContainer();
engine.mount(container);
const term = terminalCache.get('cd-wired')!.terminal as any;

const focusBefore = term.focusCount;
container.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(term.focusCount).toBe(focusBefore + 1);

container.dispatchEvent(
new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }),
);
expect(openSearchCalls).toBe(1);

container.dispatchEvent(
new KeyboardEvent('keydown', { key: '=', ctrlKey: true, bubbles: true }),
);
expect(zoomCalls).toBe(1);

container.dispatchEvent(
new WheelEvent('wheel', { deltaY: -1, ctrlKey: true, bubbles: true }),
);
expect(zoomCalls).toBe(2);
});

// §13 T13, last clause / §5.5 site 4. Without the new dispose line at :742 a
// remount leaves the PREVIOUS container's four listeners attached to the
// abandoned node — they would keep focusing a terminal from a dead pane.
it('disposes the previous mount\'s container listeners on a mount() without unmount()', () => {
let openSearchCalls = 0;
const engine = new TerminalEngine(makeFakeBridge(), {
cacheKey: 'cd-remount',
isMac: false,
onOpenSearch: () => { openSearchCalls += 1; },
});
const a = makeContainer();
engine.mount(a);
const term = terminalCache.get('cd-remount')!.terminal as any;

const b = makeContainer();
engine.mount(b);

const focusBefore = term.focusCount;
a.dispatchEvent(new MouseEvent('click', { bubbles: true }));
a.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
expect(term.focusCount).toBe(focusBefore);
expect(openSearchCalls).toBe(0);

// …and the NEW container is fully wired.
b.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(term.focusCount).toBe(focusBefore + 1);
expect((engine as any).containerDisposables.length).toBe(4);
});

// §13 T13, second clause: unmount() must run BOTH arrays. Before the split it
// ran one; if it kept running only `disposables` the four listeners would
// outlive the engine.
it('runs both arrays on unmount()', () => {
const engine = new TerminalEngine(makeFakeBridge(), { cacheKey: 'cd-unmount' });
const container = makeContainer();
engine.mount(container);
const term = terminalCache.get('cd-unmount')!.terminal as any;

engine.unmount();
expect((engine as any).containerDisposables).toEqual([]);

const focusBefore = term.focusCount;
container.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(term.focusCount).toBe(focusBefore);
});

// §13 T4, mount() half / D7: the observer is NOT a containerDisposable. It has
// exactly one owner and one explicit disconnect, which lifecycle-timers.test.ts
// :109-148 already pins for the mount()/unmount() paths.
it('keeps the ResizeObserver out of containerDisposables', () => {
const engine = new TerminalEngine(makeFakeBridge(), { cacheKey: 'cd-ro' });
engine.mount(makeContainer());
expect((engine as any).containerDisposables.length).toBe(4);
expect((engine as any).resizeObserver).not.toBeNull();
});
});
Loading
Loading