Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 29 additions & 16 deletions apps/server/src/terminal/Layers/BunPTY.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Effect, Layer } from "effect";
import { PtyAdapter } from "../Services/PTY.ts";
import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts";
import {
PtySpawnError,
type PtyAdapterShape,
type PtyExitEvent,
type PtyProcess,
} from "../Services/PTY.ts";

class BunPtyProcess implements PtyProcess {
private readonly dataListeners = new Set<(data: string) => void>();
Expand Down Expand Up @@ -99,22 +104,30 @@ export const layer = Layer.effect(
}
return {
spawn: (input) =>
Effect.sync(() => {
let processHandle: BunPtyProcess | null = null;
const command = [input.shell, ...(input.args ?? [])];
const subprocess = Bun.spawn(command, {
cwd: input.cwd,
env: input.env,
terminal: {
cols: input.cols,
rows: input.rows,
data: (_terminal, data) => {
processHandle?.emitData(data);
Effect.try({
try: () => {
let processHandle: BunPtyProcess | null = null;
const command = [input.shell, ...(input.args ?? [])];
const subprocess = Bun.spawn(command, {
cwd: input.cwd,
env: input.env,
terminal: {
cols: input.cols,
rows: input.rows,
data: (_terminal, data) => {
processHandle?.emitData(data);
},
},
},
});
processHandle = new BunPtyProcess(subprocess);
return processHandle;
});
processHandle = new BunPtyProcess(subprocess);
return processHandle as PtyProcess;
},
catch: (cause) =>
new PtySpawnError({
adapter: "bun-pty",
message: cause instanceof Error ? cause.message : "Failed to spawn PTY process",
cause,
}),
}),
} satisfies PtyAdapterShape;
}),
Expand Down
11 changes: 3 additions & 8 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
type ThreadTerminalGroup,
} from "../types";
import { readEnvironmentApi } from "~/environmentApi";
import { extractErrorMessage } from "~/lib/errorMessage";
import { readLocalApi } from "~/localApi";
import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore";

Expand Down Expand Up @@ -517,10 +518,7 @@ export function TerminalViewport({
void api.terminal
.write({ threadId, terminalId, data })
.catch((err) =>
writeSystemMessage(
terminal,
err instanceof Error ? err.message : "Terminal write failed",
),
writeSystemMessage(terminal, extractErrorMessage(err, "Terminal write failed")),
);
});

Expand Down Expand Up @@ -702,10 +700,7 @@ export function TerminalViewport({
}
} catch (err) {
if (disposed) return;
writeSystemMessage(
terminal,
err instanceof Error ? err.message : "Failed to open terminal",
);
writeSystemMessage(terminal, extractErrorMessage(err, "Failed to open terminal"));
}
};

Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/lib/errorMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { extractErrorMessage } from "./errorMessage";

describe("extractErrorMessage", () => {
const fallback = "fallback message";

it("returns the message of a regular Error", () => {
const err = new Error("boom");
expect(extractErrorMessage(err, fallback)).toBe("boom");
});

it("returns fallback when Error has empty message", () => {
const err = new Error("");
expect(extractErrorMessage(err, fallback)).toBe(fallback);
});

it("returns fallback when Error message is whitespace", () => {
const err = new Error(" ");
expect(extractErrorMessage(err, fallback)).toBe(fallback);
});

it("extracts message from a non-Error object with a message string", () => {
const err = { message: "RPC defect: failed to spawn" };
expect(extractErrorMessage(err, fallback)).toBe("RPC defect: failed to spawn");
});

it("formats objects carrying a _tag with the serialized payload", () => {
const err = { _tag: "TerminalCwdError", cwd: "/tmp/missing", reason: "notFound" };
expect(extractErrorMessage(err, fallback)).toContain("TerminalCwdError");
expect(extractErrorMessage(err, fallback)).toContain("/tmp/missing");
});

it("prefers object.message over _tag formatting", () => {
const err = { _tag: "Foo", message: "explicit message" };
expect(extractErrorMessage(err, fallback)).toBe("explicit message");
});

it("returns string rejections directly", () => {
expect(extractErrorMessage("a literal string defect", fallback)).toBe(
"a literal string defect",
);
});

it("returns fallback for null", () => {
expect(extractErrorMessage(null, fallback)).toBe(fallback);
});

it("returns fallback for undefined", () => {
expect(extractErrorMessage(undefined, fallback)).toBe(fallback);
});

it("returns fallback for numeric rejections", () => {
expect(extractErrorMessage(42, fallback)).toBe(fallback);
});

it("survives circular objects without throwing", () => {
const err: { _tag: string; self?: unknown } = { _tag: "Circular" };
err.self = err;
expect(extractErrorMessage(err, fallback)).toBe("Circular");
});
});
35 changes: 35 additions & 0 deletions apps/web/src/lib/errorMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export function extractErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) {
const message = error.message;
if (typeof message === "string" && message.trim().length > 0) {
return message;
}
}

if (typeof error === "object" && error !== null) {
const messageCandidate = (error as { message?: unknown }).message;
if (typeof messageCandidate === "string" && messageCandidate.trim().length > 0) {
return messageCandidate;
}

const tagCandidate = (error as { _tag?: unknown })._tag;
if (typeof tagCandidate === "string" && tagCandidate.length > 0) {
const serialized = safeStringify(error);
return serialized ? `${tagCandidate}: ${serialized}` : tagCandidate;
}
}

if (typeof error === "string" && error.trim().length > 0) {
return error;
}

return fallback;
}

function safeStringify(value: unknown): string | null {
try {
return JSON.stringify(value);
} catch {
return null;
}
}
30 changes: 30 additions & 0 deletions packages/contracts/src/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,36 @@ describe("TerminalOpenInput", () => {
).toBe(false);
});

it("accepts maximum cols and rows", () => {
expect(
decodes(TerminalOpenInput, {
threadId: "thread-1",
cwd: "/tmp/project",
cols: 1000,
rows: 500,
}),
).toBe(true);
});

it("rejects cols and rows above maximum", () => {
expect(
decodes(TerminalOpenInput, {
threadId: "thread-1",
cwd: "/tmp/project",
cols: 1001,
rows: 24,
}),
).toBe(false);
expect(
decodes(TerminalOpenInput, {
threadId: "thread-1",
cwd: "/tmp/project",
cols: 80,
rows: 501,
}),
).toBe(false);
});

it("defaults terminalId when missing", () => {
const parsed = decodeSync(TerminalOpenInput, {
threadId: "thread-1",
Expand Down
4 changes: 2 additions & 2 deletions packages/contracts/src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ export const DEFAULT_TERMINAL_ID = "default";

const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check(
Schema.isLessThanOrEqualTo(400),
Schema.isLessThanOrEqualTo(1000),
);
const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check(
Schema.isLessThanOrEqualTo(200),
Schema.isLessThanOrEqualTo(500),
);
const TerminalIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128));
const TerminalEnvKeySchema = Schema.String.check(
Expand Down
Loading