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
144 changes: 144 additions & 0 deletions app/api/pty/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { NextResponse } from "next/server";
import { getPtySession } from "@/lib/pty-manager";

export const dynamic = "force-dynamic";

// GET /api/pty/[id] — SSE stream: replays the backlog, then live output.
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const session = getPtySession(id);
if (!session) return new Response("Terminal session not found", { status: 404 });
if (req.signal.aborted) return new Response(null, { status: 204 });

const encoder = new TextEncoder();
let closed = false;
let unsubscribeOutput: (() => void) | null = null;
let unsubscribeExit: (() => void) | null = null;
let heartbeat: ReturnType<typeof setInterval> | null = null;
let acquired = false;

const stream = new ReadableStream<Uint8Array>({
start(controller) {
const send = (payload: unknown) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
} catch {
cleanup();
}
};
const cleanup = () => {
if (closed) return;
closed = true;
if (heartbeat !== null) clearInterval(heartbeat);
unsubscribeOutput?.();
unsubscribeExit?.();
if (acquired) {
acquired = false;
session.release();
}
if (req.signal.aborted) return;
try { controller.close(); } catch { /* already closed */ }
};

// "start" lets the client reset before the backlog replay so
// EventSource auto-reconnects render exactly once.
send({ type: "start" });
send({ type: "data", data: session.takeBacklog() });
if (!session.isAlive()) {
send({ type: "exit" });
cleanup();
return;
}

acquired = true;
session.acquire();

unsubscribeOutput = session.onOutput((data) => send({ type: "data", data }));
unsubscribeExit = session.onExit(() => {
send({ type: "exit" });
cleanup();
});
heartbeat = setInterval(() => {
if (closed) return;
try {
controller.enqueue(encoder.encode(": ping\n\n"));
} catch {
cleanup();
}
}, 30_000);

req.signal.addEventListener("abort", cleanup, { once: true });
},
cancel() {
closed = true;
unsubscribeOutput?.();
unsubscribeExit?.();
if (heartbeat !== null) clearInterval(heartbeat);
if (acquired) {
acquired = false;
session.release();
}
},
});

return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

// POST /api/pty/[id] body: { action: "input" | "resize", data? | cols?, rows? }
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const session = getPtySession(id);
if (!session) return NextResponse.json({ error: "Terminal session not found" }, { status: 404 });

const body = await req.json().catch(() => ({})) as {
action?: unknown;
data?: unknown;
cols?: unknown;
rows?: unknown;
};

if (body.action === "input") {
if (typeof body.data !== "string") {
return NextResponse.json({ error: "data must be a string" }, { status: 400 });
}
session.write(body.data);
return NextResponse.json({ ok: true });
}

if (body.action === "resize") {
const cols = Number(body.cols);
const rows = Number(body.rows);
if (!Number.isFinite(cols) || !Number.isFinite(rows)) {
return NextResponse.json({ error: "cols and rows are required" }, { status: 400 });
}
session.resize(Math.floor(cols), Math.floor(rows));
return NextResponse.json({ ok: true });
}

return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}

// DELETE /api/pty/[id] — kill the terminal process.
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const session = getPtySession(id);
if (session) session.kill();
return NextResponse.json({ ok: true });
}
20 changes: 20 additions & 0 deletions app/api/pty/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { createPtySession } from "@/lib/pty-manager";

export const dynamic = "force-dynamic";

// POST /api/pty body: { cwd?, cols?, rows? }
// Spawns a shell in the requested directory, falling back to ~ when missing.
export async function POST(req: Request) {
try {
const body = await req.json().catch(() => ({})) as { cwd?: unknown; cols?: unknown; rows?: unknown };
const session = createPtySession({
cwd: typeof body.cwd === "string" ? body.cwd : null,
cols: typeof body.cols === "number" ? body.cols : undefined,
rows: typeof body.rows === "number" ? body.rows : undefined,
});
return NextResponse.json({ id: session.id, cwd: session.cwd });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
30 changes: 30 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,36 @@ span.token.table {
.panel-resize-handle.is-resizing::after {
background: color-mix(in srgb, var(--text-muted) 70%, var(--border));
}

/* Sidebar inner split between the session list and the file explorer.
The explorer's top border stays the idle separator; this element only
expands the hit target. */
.sidebar-explorer-split-handle {
position: relative;
z-index: 5;
height: 10px;
margin: -5px 0;
flex: 0 0 10px;
cursor: row-resize;
touch-action: none;
outline: none;
}
.sidebar-explorer-split-handle::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 5px;
height: 2px;
pointer-events: none;
background: transparent;
transition: background 0.12s ease;
}
.sidebar-explorer-split-handle:hover::after,
.sidebar-explorer-split-handle:focus-visible::after,
.sidebar-explorer-split-handle.is-resizing::after {
background: color-mix(in srgb, var(--text-muted) 70%, var(--border));
}
.right-panel-overlay-backdrop {
display: none;
}
Expand Down
46 changes: 45 additions & 1 deletion components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { openFileTab, saveFileViewerState } from "./file-tab-state";
import { ModelsConfig } from "./ModelsConfig";
import { SkillsConfig } from "./SkillsConfig";
import { PluginsConfig } from "./PluginsConfig";
import { TerminalPanel } from "./TerminalPanel";
import { ProjectTrustDialog } from "./ProjectTrustDialog";
import { BranchNavigator } from "./BranchNavigator";
import { useTheme } from "@/hooks/useTheme";
Expand Down Expand Up @@ -108,6 +109,7 @@ export function AppShell() {
const [projectTrustError, setProjectTrustError] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [rightPanelOpen, setRightPanelOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [mobileToolbarMoreOpen, setMobileToolbarMoreOpen] = useState(false);
const [mobileSidebarReady, setMobileSidebarReady] = useState(false);
const sidebarWidthRef = useRef(SIDEBAR_DEFAULT_WIDTH);
Expand Down Expand Up @@ -605,9 +607,10 @@ export function AppShell() {
router.replace("/", { scroll: false });
}, [invalidateWorkspaceRestore, router, isMobile]);

// Global keyboard shortcuts (handles Esc, Ctrl+Alt+N etc.)
// Global keyboard shortcuts (handles Esc, Ctrl+Alt+N, Ctrl+` etc.)
useGlobalKeyboardShortcuts({
onNewSession: (cwd: string) => handleNewSession(`kb-${Date.now()}`, cwd),
onToggleTerminal: () => setTerminalOpen((open) => !open),
activeCwd,
});

Expand Down Expand Up @@ -1555,6 +1558,39 @@ export function AppShell() {
);
};

const renderTerminalToggle = (mobile: boolean) => {
const covered = mobile && mobileToolbarMoreOpen;
return (
<button
type="button"
onClick={() => setTerminalOpen((open) => !open)}
disabled={covered}
tabIndex={covered ? -1 : undefined}
aria-expanded={terminalOpen}
aria-hidden={covered ? true : undefined}
title={terminalOpen ? translate("terminal.hide") : translate("terminal.show")}
aria-label={terminalOpen ? translate("terminal.hide") : translate("terminal.show")}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: TOP_BAR_ICON_BUTTON_SIZE, height: TOP_BAR_ICON_BUTTON_SIZE, padding: 0,
visibility: covered ? "hidden" : "visible",
pointerEvents: covered ? "none" : "auto",
background: terminalOpen ? "var(--bg-selected)" : "none",
border: "none", borderLeft: "1px solid var(--border)",
color: terminalOpen ? "var(--text)" : "var(--text-muted)",
cursor: "pointer", flexShrink: 0, transition: "color 0.12s, background 0.12s",
}}
onMouseEnter={(event) => { if (!covered) event.currentTarget.style.color = "var(--text)"; }}
onMouseLeave={(event) => { event.currentTarget.style.color = terminalOpen ? "var(--text)" : "var(--text-muted)"; }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
</button>
);
};

return (
<>
<style>{`
Expand Down Expand Up @@ -1765,6 +1801,7 @@ export function AppShell() {
)}
</button>
{renderSessionStatsButton(true)}
{renderTerminalToggle(true)}
{renderMainFileToggle(true)}
{mobileToolbarMoreOpen && (
<div
Expand Down Expand Up @@ -1801,6 +1838,7 @@ export function AppShell() {
</>
)}
{!isMobile && renderMainFileToggle(false)}
{!isMobile && renderTerminalToggle(false)}
{isMobile && (
<BranchNavigator
tree={branchTree}
Expand Down Expand Up @@ -2144,6 +2182,12 @@ export function AppShell() {
)
) : null}
</div>
{/* Always mounted; hiding keeps PTY sessions and scrollback alive */}
<TerminalPanel
open={terminalOpen}
cwd={activeCwd ?? selectedSession?.cwd ?? effectiveNewSessionCwd ?? null}
onClose={() => setTerminalOpen(false)}
/>
</div>

<div
Expand Down
Loading