diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index c57c6d227d033afba211cace3b9c25828bfe63a8..4fa52c843d321c34b86fc2951d1e7ac4f3bc22b4 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -299,6 +299,21 @@ export default function ChatView() {
},
[activeThreadId, dispatch],
);
+ const closeTerminal = useCallback(
+ (terminalId: string) => {
+ if (!activeThreadId || !api) return;
+ void api.terminal.write({ threadId: activeThreadId, terminalId, data: "exit\n" }).catch(() => {
+ // Ignore write errors so the UI can still close an uninitialized or already-exited terminal.
+ });
+ dispatch({
+ type: "CLOSE_THREAD_TERMINAL",
+ threadId: activeThreadId,
+ terminalId,
+ });
+ setTerminalFocusRequestId((value) => value + 1);
+ },
+ [activeThreadId, api, dispatch],
+ );
const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
if (mode === state.runtimeMode) return;
@@ -1489,6 +1504,7 @@ export default function ChatView() {
onSplitTerminal={splitTerminal}
onNewTerminal={createNewTerminal}
onActiveTerminalChange={activateTerminal}
+ onCloseTerminal={closeTerminal}
onHeightChange={(height) =>
dispatch({
type: "SET_THREAD_TERMINAL_HEIGHT",
diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx
index fd9dc07786025b86bdd89785eb0658e684c2f25b..783e5886481d879e647f1645b10ca9d80e381751 100644
--- a/apps/web/src/components/ThreadTerminalDrawer.tsx
+++ b/apps/web/src/components/ThreadTerminalDrawer.tsx
@@ -421,6 +421,7 @@ interface ThreadTerminalDrawerProps {
onSplitTerminal: () => void;
onNewTerminal: () => void;
onActiveTerminalChange: (terminalId: string) => void;
+ onCloseTerminal: (terminalId: string) => void;
onHeightChange: (height: number) => void;
}
@@ -437,6 +438,7 @@ export default function ThreadTerminalDrawer({
onSplitTerminal,
onNewTerminal,
onActiveTerminalChange,
+ onCloseTerminal,
onHeightChange,
}: ThreadTerminalDrawerProps) {
const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
@@ -482,6 +484,13 @@ export default function ThreadTerminalDrawer({
: [resolvedActiveTerminalId];
const showTabBar = terminalLayout === "tabs" && normalizedTerminalIds.length > 1;
+ const terminalLabelById = useMemo(
+ () =>
+ new Map(
+ normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]),
+ ),
+ [normalizedTerminalIds],
+ );
useEffect(() => {
onHeightChangeRef.current = onHeightChange;
@@ -593,21 +602,37 @@ export default function ThreadTerminalDrawer({
{showTabBar ? (
- {normalizedTerminalIds.map((terminalId, index) => {
+ {normalizedTerminalIds.map((terminalId) => {
const isActive = terminalId === resolvedActiveTerminalId;
return (
-
+
+
+
);
})}
@@ -632,6 +657,14 @@ export default function ThreadTerminalDrawer({
>
New Terminal
+
+
@@ -639,17 +672,45 @@ export default function ThreadTerminalDrawer({
{terminalLayout === "split" && visibleTerminalIds.length === 2 ? (
{visibleTerminalIds.map((terminalId) => (
-
+ className="flex min-h-0 flex-col rounded-[4px] border border-border/70"
+ >
+
+
+
+
+
+
+
+
))}
) : (
diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts
index a06efcd063f0e6c6d91585aa25608d261b5b24b3..8e5e6d011c3a3b83e5ad9751b5920ba0277924a5 100644
--- a/apps/web/src/store.test.ts
+++ b/apps/web/src/store.test.ts
@@ -160,6 +160,42 @@ describe("store reducer thread continuity", () => {
expect(next.threads[0]?.activeTerminalId).toBe("term-2");
});
+ it("closes a terminal tab and falls back to single layout when one remains", () => {
+ const state = makeState(
+ makeThread({
+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
+ activeTerminalId: "term-2",
+ terminalLayout: "tabs",
+ }),
+ );
+ const next = reducer(state, {
+ type: "CLOSE_THREAD_TERMINAL",
+ threadId: "thread-local-1",
+ terminalId: "term-2",
+ });
+
+ expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
+ expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
+ expect(next.threads[0]?.terminalLayout).toBe("single");
+ });
+
+ it("closes the final terminal and hides the drawer", () => {
+ const state = makeState(
+ makeThread({
+ terminalOpen: true,
+ }),
+ );
+ const next = reducer(state, {
+ type: "CLOSE_THREAD_TERMINAL",
+ threadId: "thread-local-1",
+ terminalId: DEFAULT_THREAD_TERMINAL_ID,
+ });
+
+ expect(next.threads[0]?.terminalOpen).toBe(false);
+ expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
+ expect(next.threads[0]?.terminalLayout).toBe("single");
+ });
+
it("backfills codexThreadId from routed provider events", () => {
const state = makeState(makeThread({ codexThreadId: null }));
const next = reducer(state, {
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
index 039cf3636987ae321bdd729067df1606da12db53..bd2fe31a9b94c9547e23383da9154463d0aae698 100644
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -35,6 +35,7 @@ type Action =
| { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string }
| { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }
| { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }
+ | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }
| { type: "TOGGLE_DIFF" }
| {
type: "APPLY_EVENT";
@@ -189,6 +190,48 @@ function normalizeThreadTerminals(thread: Thread): Thread {
};
}
+function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
+ if (!thread.terminalIds.includes(terminalId)) {
+ return thread;
+ }
+
+ const remainingTerminalIds = thread.terminalIds.filter((id) => id !== terminalId);
+ if (remainingTerminalIds.length === 0) {
+ return normalizeThreadTerminals({
+ ...thread,
+ terminalOpen: false,
+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
+ activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
+ terminalLayout: "single",
+ splitTerminalIds: [],
+ });
+ }
+
+ const nextActiveTerminalId =
+ thread.activeTerminalId === terminalId
+ ? (remainingTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID)
+ : thread.activeTerminalId;
+ let nextLayout = thread.terminalLayout;
+ let nextSplitTerminalIds = thread.splitTerminalIds.filter((id) => id !== terminalId);
+
+ if (nextLayout === "split" && nextSplitTerminalIds.length < 2) {
+ nextLayout = remainingTerminalIds.length > 1 ? "tabs" : "single";
+ nextSplitTerminalIds = [];
+ }
+
+ if (nextLayout === "tabs" && remainingTerminalIds.length < 2) {
+ nextLayout = "single";
+ }
+
+ return normalizeThreadTerminals({
+ ...thread,
+ terminalIds: remainingTerminalIds,
+ activeTerminalId: nextActiveTerminalId,
+ terminalLayout: nextLayout,
+ splitTerminalIds: nextSplitTerminalIds,
+ });
+}
+
function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {
return threads.find((t) => t.session?.sessionId === sessionId);
}
@@ -454,6 +497,14 @@ export function reducer(state: AppState, action: Action): AppState {
}),
};
+ case "CLOSE_THREAD_TERMINAL":
+ return {
+ ...state,
+ threads: updateThread(state.threads, action.threadId, (thread) =>
+ closeThreadTerminal(thread, action.terminalId),
+ ),
+ };
+
case "TOGGLE_DIFF":
return { ...state, diffOpen: !state.diffOpen };