diff --git a/app/api/pty/[id]/route.ts b/app/api/pty/[id]/route.ts new file mode 100644 index 000000000..dbaca1904 --- /dev/null +++ b/app/api/pty/[id]/route.ts @@ -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 | null = null; + let acquired = false; + + const stream = new ReadableStream({ + 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 }); +} diff --git a/app/api/pty/route.ts b/app/api/pty/route.ts new file mode 100644 index 000000000..da3d60894 --- /dev/null +++ b/app/api/pty/route.ts @@ -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 }); + } +} diff --git a/app/globals.css b/app/globals.css index bf2dc3da4..fdc2a249a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 4f1822a70..53d5e914f 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -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"; @@ -108,6 +109,7 @@ export function AppShell() { const [projectTrustError, setProjectTrustError] = useState(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); @@ -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, }); @@ -1555,6 +1558,39 @@ export function AppShell() { ); }; + const renderTerminalToggle = (mobile: boolean) => { + const covered = mobile && mobileToolbarMoreOpen; + return ( + + ); + }; + return ( <>