Skip to content
Open
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
c0b7e66
fix(server): make node-pty failures loud and actionable
StiensWout Aug 1, 2026
9f262f4
fix(web): terminal close shortcut can no longer reach the browser def…
StiensWout Aug 1, 2026
143087f
fix(desktop): holding Ctrl+W no longer closes the window
StiensWout Aug 1, 2026
95968fd
fix(server): stop shell fallback once spawn-helper is diagnosed
StiensWout Aug 1, 2026
8bca3c5
fix(web): new terminals no longer flash black while the renderer loads
StiensWout Aug 1, 2026
a8ebfe1
fix(web): bottom-bar splits no longer collapse into a separate group
StiensWout Aug 1, 2026
45bc0dc
refactor(server): type the spawn-helper diagnosis and move exit polic…
StiensWout Aug 1, 2026
ca8a88d
fix(server): judge spawn-helper executability by the calling process
StiensWout Aug 1, 2026
5712374
fix(terminal): close failures, held close chord, and split merges no …
StiensWout Aug 1, 2026
79be836
fix(web): track pending opens so reconcile can drop remote closes
StiensWout Aug 1, 2026
35f2a14
fix(web): drop pending terminals whose open request failed
StiensWout Aug 1, 2026
3bfce9b
fix(web): failed closes restore the terminal where it lived
StiensWout Aug 1, 2026
e0ad1c8
fix(web): never reuse a terminal id that may still have an open in fl…
StiensWout Aug 1, 2026
e72a980
fix(web): make terminal open/close rollbacks precise about unknown ou…
StiensWout Aug 1, 2026
5e0c3c3
fix(web): settle panel terminal opens and closes against server metadata
StiensWout Aug 1, 2026
5fd1ac3
fix(web): resize the PTY in lockstep with the terminal grid
StiensWout Aug 1, 2026
58bf3de
fix(web): apply grid resizes only once the PTY acknowledges
StiensWout Aug 1, 2026
c9b75cf
fix(web): never skip a PTY width the shell already redrew for
StiensWout Aug 1, 2026
80706b0
fix(web): settle recorded panel closes immediately and honor denied r…
StiensWout Aug 1, 2026
e38c250
fix(terminal): close three review gaps in resize and pending-close ha…
StiensWout Aug 1, 2026
8026b6a
fix(terminal): order resize replies behind queued output and close ro…
StiensWout Aug 1, 2026
69a7132
fix(server): strip replayable query traffic from terminal history
StiensWout Aug 1, 2026
013841c
fix(server): flush the resize reply while a drain is still publishing
StiensWout Aug 1, 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
13 changes: 13 additions & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,19 @@ export const make = Effect.gen(function* () {
}
});

// Electron's windowMenu close role owns CmdOrCtrl+W. Holding the
// close-terminal shortcut auto-repeats faster than the renderer can re-arm
// its guard after the last terminal closes, and a single leaked repeat
// closes the whole window (and on Windows/Linux, the app). Only deliberate,
// non-repeated presses may act.
window.webContents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || !input.isAutoRepeat) return;
const modifier = environment.platform === "darwin" ? input.meta : input.control;
if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") {
event.preventDefault();
}
});

window.on("page-title-updated", (event) => {
event.preventDefault();
window.setTitle(environment.displayName);
Expand Down
24 changes: 23 additions & 1 deletion apps/server/src/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ import { ServerConfig, type StartupPresentation } from "../config.ts";
import { runServer } from "../server.ts";
import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts";

/**
* Defects thrown while building the server layer can be swallowed by headless
* startup logging, which used to make `npx t3` exit cleanly with no output on a
* broken install. The entrypoint owns process-exit policy, so render whatever
* diagnosis the defect carries and make sure the exit code is non-zero.
*/
const hasDiagnostic = (defect: unknown): defect is { readonly diagnostic: string } =>
typeof defect === "object" &&
defect !== null &&
typeof (defect as { diagnostic?: unknown }).diagnostic === "string";

const reportStartupDefect = (defect: unknown) =>
Effect.sync(() => {
process.exitCode = 1;
if (hasDiagnostic(defect)) {
process.stderr.write(`${defect.diagnostic}\n`);
}
});

export const runServerCommand = (
flags: CliServerFlags,
options?: {
Expand All @@ -15,7 +34,10 @@ export const runServerCommand = (
Effect.gen(function* () {
const logLevel = yield* GlobalFlag.LogLevel;
const config = yield* resolveServerConfig(flags, logLevel, options);
return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
return yield* runServer.pipe(
Effect.provideService(ServerConfig, config),
Effect.tapDefect(reportStartupDefect),
);
});

export const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
Expand Down
78 changes: 78 additions & 0 deletions apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,33 @@ it.layer(
}),
);

it.effect("orders the resize reply behind output queued at the old width", () =>
Effect.gen(function* () {
const { manager, ptyAdapter, getEvents } = yield* createManager();
yield* manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

// Queued but not yet drained: produced at the pre-resize width.
process.emitData("old-width bytes\n");

yield* manager.resize({
threadId: "thread-1",
terminalId: DEFAULT_TERMINAL_ID,
cols: 42,
rows: 20,
});

// By the time the resize reply lands, the old-width output must already
// be published — the client applies its grid on this reply.
const events = yield* getEvents;
const outputEvent = events.find((event) => event.type === "output");
expect(outputEvent).toBeDefined();
expect(process.resizeCalls).toEqual([{ cols: 42, rows: 20 }]);
}),
);

it.effect("preserves structured context and causes for PTY I/O failures", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager();
Expand Down Expand Up @@ -991,6 +1018,34 @@ it.layer(
}),
);

it.effect("strips mode, version, and DCS query traffic that reopening would replay", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager();
yield* manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

// The families behind the reopen gibberish from the field reports:
// DECRQM query + DECRPM replies, DECRQSS/XTGETTCAP query + reply, and
// XTVERSION/kitty-keyboard queries.
process.emitData("prompt ");
process.emitData("\u001b[?2026$p\u001b[?2026;2$y\u001b[?2027;0$y");
process.emitData("\u001bP$q m\u001b\\\u001bP1$r0m\u001b\\");
process.emitData("\u001bP+q544e\u001b\\\u001bP1+r544e=1b\u001b\\");
process.emitData("\u001b[>q\u001b[?u\u001b[?31u");
// Setters that share final bytes with the stripped queries must survive:
// DECSCUSR (cursor style) and restore-cursor.
process.emitData("\u001b[4 q\u001b[umid ");
process.emitData("done\n");

yield* manager.close({ threadId: "thread-1" });

const reopened = yield* manager.open(openInput());
assert.equal(reopened.history, "prompt \u001b[4 q\u001b[umid done\n");
}),
);

it.effect(
"preserves clear and style control sequences while dropping chunk-split query traffic",
() =>
Expand Down Expand Up @@ -1260,6 +1315,29 @@ it.layer(
}),
);

it.effect("does not retry other shells when spawn-helper is not executable", () =>
Effect.gen(function* () {
const { manager, ptyAdapter, getEvents } = yield* createManager();
ptyAdapter.spawnFailures.push(
new PtyAdapter.SpawnHelperNotExecutableError({
helperPath: "/pkg/spawn-helper",
cause: new Error("posix_spawnp failed."),
}),
);

const snapshot = yield* manager.open(openInput());

assert.equal(snapshot.status, "error");
expect(ptyAdapter.spawnInputs).toHaveLength(1);

// The user must see the fix, not the generic "tried these shells" wrapper.
const events = yield* getEvents;
const errorEvent = events.find((event) => event.type === "error");
assert.isDefined(errorEvent);
assert.include(errorEvent?.message ?? "", 'chmod +x "/pkg/spawn-helper"');
}),
);

it.effect("prefers PowerShell over ComSpec for Windows terminals", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager(5, {
Expand Down
Loading
Loading