Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cb6a174
feat: add ToolsConfig panel with individual tool toggles persisted to…
fallleave May 31, 2026
7b833b2
fix: remove tree field from session detail API to prevent stack overf…
fallleave May 31, 2026
f06ef25
fix: read pi and app versions at runtime instead of baking at build time
fallleave May 31, 2026
d5751b0
chore: upgrade pi-coding-agent to 0.78.0
fallleave May 31, 2026
e5d77dd
Merge branch 'fix/session-tree-overflow' into dev
fallleave May 31, 2026
09d729c
Merge branch 'fix/runtime-version' into dev
fallleave May 31, 2026
c63d22e
fix: keep tree field but compress linear chains to prevent JSON.strin…
fallleave Jun 4, 2026
0fc978f
fix: keep tree field but compress linear chains to prevent JSON.strin…
fallleave Jun 4, 2026
8f5231f
fix: keep tree field but compress linear chains to prevent JSON.strin…
fallleave Jun 4, 2026
b02b9b2
Merge branch 'fix/session-tree-overflow' into dev
fallleave Jun 4, 2026
995adc4
fix: apply updated compressTree fix from fix/session-tree-overflow br…
fallleave Jun 4, 2026
327c02c
fix: persist empty activeTools array to prevent re-enabling defaults …
fallleave Jun 5, 2026
b6f0934
fix: also handle empty activeTools array in rpc-manager.ts to respect…
fallleave Jun 5, 2026
a69c6ef
fix: also respect empty activeTools in frontend when creating new ses…
fallleave Jun 5, 2026
6904939
chore: upgrade pi-ai and pi-coding-agent to v0.78.1
fallleave Jun 5, 2026
ba051d7
feat: add PDF and DOCX file preview support with live sync and downlo…
agegr Jun 4, 2026
7bcefaf
feat: add Mermaid diagram rendering with live preview toggle and them…
agegr Jun 4, 2026
31881ab
fix: preserve terminal leaves in compressTree and use iterative chain…
fallleave Jun 9, 2026
953a2cf
fix: preserve chain head and tail in compressTree, skip middle iterat…
fallleave Jun 9, 2026
e98a05a
fix: hide typewriter on mobile (<=640px) to prevent layout flicker
Jun 11, 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
139 changes: 139 additions & 0 deletions app/api/files/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const IGNORED_SUFFIXES = [".pyc"];

const TEXT_PREVIEW_MAX_BYTES = 256 * 1024;
const IMAGE_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;
const DOCX_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;

const IMAGE_EXT_TO_MIME: Record<string, string> = {
png: "image/png",
Expand All @@ -39,6 +40,11 @@ const AUDIO_EXT_TO_MIME: Record<string, string> = {
webm: "audio/webm",
};

const DOCUMENT_EXT_TO_MIME: Record<string, string> = {
pdf: "application/pdf",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
};

function getExt(filePath: string): string {
const ext = path.basename(filePath).toLowerCase().split(".").pop() ?? "";
return ext;
Expand All @@ -52,6 +58,10 @@ function getAudioMime(filePath: string): string | null {
return AUDIO_EXT_TO_MIME[getExt(filePath)] ?? null;
}

function getDocumentMime(filePath: string): string | null {
return DOCUMENT_EXT_TO_MIME[getExt(filePath)] ?? null;
}

const EXT_TO_LANGUAGE: Record<string, string> = {
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby",
Expand All @@ -64,6 +74,7 @@ const EXT_TO_LANGUAGE: Record<string, string> = {
sql: "sql", graphql: "graphql", gql: "graphql",
dockerfile: "dockerfile", tf: "hcl", hcl: "hcl",
env: "bash", gitignore: "bash", txt: "text",
pdf: "pdf", docx: "word",
};

function getLanguage(filePath: string): string {
Expand Down Expand Up @@ -187,11 +198,24 @@ function createFileBodyStream(filePath: string, range?: { start: number; end: nu
});
}

function encodeHeaderValue(value: string): string {
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
);
}

function getContentDisposition(filePath: string): string {
const fileName = path.basename(filePath);
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "download";
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
}

function streamFile(filePath: string, stat: fs.Stats, contentType: string, rangeHeader: string | null): Response {
const headers = {
"Content-Type": contentType,
"Cache-Control": "no-cache",
"Accept-Ranges": "bytes",
"Content-Disposition": getContentDisposition(filePath),
};

if (!rangeHeader) {
Expand Down Expand Up @@ -244,6 +268,71 @@ function streamFile(filePath: string, stat: fs.Stats, contentType: string, range
});
}

function documentPreviewKind(filePath: string): "pdf" | "docx" | null {
const ext = getExt(filePath);
if (ext === "pdf") return "pdf";
if (ext === "docx") return "docx";
return null;
}

function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

function wrapDocxPreviewHtml(bodyHtml: string, fileName: string): string {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { color-scheme: light; }
html, body { margin: 0; min-height: 100%; background: #eef1f5; color: #171717; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 28px; }
main {
box-sizing: border-box;
max-width: 840px;
min-height: calc(100vh - 56px);
margin: 0 auto;
padding: 56px 64px;
background: #fff;
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.14);
}
.file-title {
margin: 0 0 28px;
padding-bottom: 10px;
border-bottom: 1px solid #e5e7eb;
color: #6b7280;
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
word-break: break-word;
}
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.1em 0 0.45em; color: #111827; }
p { margin: 0.65em 0; line-height: 1.7; }
table { border-collapse: collapse; max-width: 100%; margin: 1em 0; }
th, td { border: 1px solid #d1d5db; padding: 6px 9px; vertical-align: top; }
img { max-width: 100%; height: auto; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
a { color: #2563eb; }
@media (max-width: 720px) {
body { padding: 0; background: #fff; }
main { min-height: 100vh; padding: 28px 22px; box-shadow: none; }
}
</style>
</head>
<body>
<main>
<div class="file-title">${escapeHtml(fileName)}</div>
${bodyHtml}
</main>
</body>
</html>`;
}

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
Expand Down Expand Up @@ -280,6 +369,10 @@ export async function GET(
if (audioMime) {
return streamFile(filePath, stat, audioMime, request.headers.get("range"));
}
const documentMime = getDocumentMime(filePath);
if (documentMime) {
return streamFile(filePath, stat, documentMime, request.headers.get("range"));
}
if (stat.size > TEXT_PREVIEW_MAX_BYTES) {
return NextResponse.json({ error: "File too large for preview (>256KB)" }, { status: 413 });
}
Expand All @@ -288,6 +381,52 @@ export async function GET(
return NextResponse.json({ content, language, size: stat.size });
}

if (type === "meta") {
if (!stat.isFile()) {
return NextResponse.json({ error: "Not a file" }, { status: 400 });
}
const imageMime = getImageMime(filePath);
const audioMime = getAudioMime(filePath);
const documentMime = getDocumentMime(filePath);
return NextResponse.json({
size: stat.size,
language: getLanguage(filePath),
mime: imageMime || audioMime || documentMime || "text/plain",
previewKind: documentPreviewKind(filePath),
});
}

if (type === "preview") {
if (!stat.isFile()) {
return NextResponse.json({ error: "Not a file" }, { status: 400 });
}
if (getExt(filePath) !== "docx") {
return NextResponse.json({ error: "Preview not available for this file type" }, { status: 400 });
}
if (stat.size > DOCX_PREVIEW_MAX_BYTES) {
return NextResponse.json({ error: "DOCX too large for preview (>10MB)" }, { status: 413 });
}

const mammoth = await import("mammoth");
const result = await mammoth.convertToHtml(
{ path: filePath },
{
externalFileAccess: false,
convertImage: mammoth.images.dataUri,
}
);
const html = wrapDocxPreviewHtml(result.value, path.basename(filePath));
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache",
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
},
});
}

if (type === "watch") {
if (!stat.isFile()) {
return NextResponse.json({ error: "Not a file" }, { status: 400 });
Expand Down
39 changes: 37 additions & 2 deletions app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,41 @@ import {
} from "@/lib/session-reader";
import { getRpcSession } from "@/lib/rpc-manager";

/**
* Compress linear single-child chains to prevent JSON.stringify stack overflow.
* Preserves the root node, branch anchors, and terminal leaves while
* contracting intermediate chains via iterative descent (no stack growth).
*/
function compressTree<T extends { children: T[] }>(nodes: T[]): T[] {
function walk(node: T): T {
// Leaf: return as-is
if (node.children.length === 0) {
return node;
}

// Linear single-child chain: skip the middle nodes iteratively
if (node.children.length === 1) {
let next = node.children[0];
while (next.children.length === 1) {
next = next.children[0];
}
// Keep current node (chain head), connect directly to the tail
return {
...node,
children: [walk(next)],
};
}

// Branch point: recurse on each child
return {
...node,
children: node.children.map(walk),
};
}

return nodes.map(walk);
}

export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
Expand All @@ -23,8 +58,8 @@ export async function GET(

const sm = SessionManager.open(filePath);
const entries = sm.getEntries() as never;
const tree = sm.getTree();
const leafId = sm.getLeafId();
const tree = compressTree(sm.getTree());
const context = buildSessionContext(entries, leafId);

const header = sm.getHeader();
Expand Down Expand Up @@ -66,8 +101,8 @@ export async function GET(
sessionId: id,
filePath,
info,
tree,
leafId,
tree,
context,
...(agentState !== undefined ? { agentState } : {}),
});
Expand Down
108 changes: 108 additions & 0 deletions app/api/tools/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { NextResponse } from "next/server";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
import {
getAgentDir,
createAgentSession,
SessionManager,
} from "@earendil-works/pi-coding-agent";

const SETTINGS_FILE = "settings.json";
const ACTIVE_TOOLS_KEY = "activeTools";

function getSettingsPath(): string {
return join(getAgentDir(), SETTINGS_FILE);
}

function readActiveTools(): string[] | null {
const path = getSettingsPath();
if (!existsSync(path)) return null;
try {
const settings = JSON.parse(readFileSync(path, "utf8"));
const tools = settings[ACTIVE_TOOLS_KEY];
return Array.isArray(tools) && tools.length > 0 ? tools : (Array.isArray(tools) ? [] : null);
} catch {
return null;
}
}

function writeActiveTools(activeTools: string[]): void {
const path = getSettingsPath();
let settings: Record<string, unknown> = {};
if (existsSync(path)) {
try {
settings = JSON.parse(readFileSync(path, "utf8"));
} catch {}
}
settings[ACTIVE_TOOLS_KEY] = activeTools;
writeFileSync(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
}

// Enumerate all available tools (built-in + extensions) by creating a temp session
async function enumerateTools(cwd: string) {
if (!cwd || !existsSync(cwd)) return [];

const agentDir = getAgentDir();
const sessionManager = SessionManager.create(cwd, undefined);
const { session } = await createAgentSession({
cwd,
agentDir,
sessionManager,
});

const allTools: { name: string; description: string; active: boolean }[] = [];
const toolEntries = session.getAllTools?.() ?? [];
const savedActive = readActiveTools();
const activeSet = savedActive
? new Set(savedActive)
: new Set(session.getActiveToolNames?.() ?? []);

for (const t of toolEntries) {
allTools.push({
name: t.name,
description: t.description ?? "",
active: activeSet.has(t.name),
});
}

session.dispose?.();
return allTools;
}

// GET /api/tools?cwd=xxx — returns saved config + tool list
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const cwd = searchParams.get("cwd");

const activeTools = readActiveTools();
let tools: { name: string; description: string; active: boolean }[] = [];

if (cwd) {
try {
tools = await enumerateTools(cwd);
} catch (e) {
console.error("Failed to enumerate tools:", e);
}
}

return NextResponse.json({ config: { activeTools }, tools });
}

// POST /api/tools — saves active tools to settings.json
// body: { activeTools: string[] }
export async function POST(req: Request) {
try {
const body = await req.json() as { activeTools: string[] };
const { activeTools } = body;
if (!Array.isArray(activeTools)) {
return NextResponse.json(
{ error: "activeTools must be an array" },
{ status: 400 }
);
}
writeActiveTools(activeTools);
return NextResponse.json({ success: true });
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
}
Loading