Skip to content
Open
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
17 changes: 14 additions & 3 deletions bin/sideshow.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ usage:
--diff <file|-> add a diff surface from a unified/git patch (combine with html)
--terminal <file|-> add a terminal surface from monospace/ANSI output
--json <file|-> add a json surface from a JSON file (collapsible tree)
--open-depth <n> expand json containers up to this depth (with --json)
--code <file|-> add a code surface from a file (shiki-highlighted)
--kit <id> opt the html surface into a kit (repeatable; see "sideshow kits")
--image <file> upload an image and append it as an image surface
Expand Down Expand Up @@ -61,6 +62,8 @@ usage:
(also: --session, --session-title, --agent, --new-session)
sideshow json <file|-> [options] publish a JSON post (collapsible tree)
--title <t> post title
--open-depth <n> expand all containers up to this depth on render
(0 = only root open, default; 2 = first 2 levels)
(also: --session, --session-title, --agent, --new-session)
sideshow code <file|-> [options] publish a code post (shiki-highlighted)
--title <t> post (card) title
Expand Down Expand Up @@ -809,6 +812,7 @@ const commands = {
image: { type: "string" },
terminal: { type: "string" },
json: { type: "string" },
"open-depth": { type: "string" },
code: { type: "string" },
kit: { type: "string", multiple: true },
layout: { type: "string" },
Expand Down Expand Up @@ -841,7 +845,10 @@ const commands = {
if (flags.json !== undefined) {
const text = readContent(flags.json || "-");
try {
parts.push({ kind: "json", data: JSON.parse(text) });
const jsonPart = { kind: "json", data: JSON.parse(text) };
const od = Number(flags["open-depth"]);
if (Number.isFinite(od) && od >= 0) jsonPart.openDepth = Math.floor(od);
parts.push(jsonPart);
} catch {
fail(`--json: invalid JSON${flags.json ? ` in ${flags.json}` : ""}`);
}
Expand Down Expand Up @@ -1017,21 +1024,25 @@ const commands = {
allowPositionals: true,
options: {
title: { type: "string" },
"open-depth": { type: "string" },
session: { type: "string" },
"session-title": { type: "string" },
agent: { type: "string" },
"new-session": { type: "boolean" },
},
});
if (!positionals[0]) fail("usage: sideshow json <file|-> [--title t]");
if (!positionals[0]) fail("usage: sideshow json <file|-> [--title t] [--open-depth n]");
const text = readContent(positionals[0]);
let data;
try {
data = JSON.parse(text);
} catch {
fail(`invalid JSON${positionals[0] !== "-" ? ` in ${positionals[0]}` : ""}`);
}
const parts = [{ kind: "json", data }];
const part = { kind: "json", data };
const od = Number(flags["open-depth"]);
if (Number.isFinite(od) && od >= 0) part.openDepth = Math.floor(od);
const parts = [part];
outSurface(await publishSurface(parts, flags));
},
async code() {
Expand Down
4 changes: 4 additions & 0 deletions guide/DESIGN_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ a `kind`:
for it for API responses, config files, test results — any structured data
where a tree beats a fenced code block. Like image/trace it is data, not
markup: the viewer renders it with escaped text nodes, so no sandbox is needed.
`openDepth` (number, default 0) expands all containers up to that depth on
initial render — e.g. `2` reveals the first two levels of nested structure.
- **`code`** — source code you hand over as _text_; the trusted viewer highlights
it with shiki (same highlighter as markdown fenced code blocks) and renders it
in a sandboxed iframe. `language` is a shiki lang id (`ts`, `js`, `python`,
Expand Down Expand Up @@ -83,6 +85,7 @@ A **`Surface`** is one of:
{ "kind": "trace", "assetId": "<id of an uploaded JSON/JSONL trace>", "title": "..." }
{ "kind": "terminal", "text": "<output, may include ANSI SGR escapes>", "cols": 80, "title": "..." }
{ "kind": "json", "data": { "a": 1, "b": [true, null, "hi"] } }
{ "kind": "json", "data": {...}, "openDepth": 2 }
{ "kind": "code", "code": "const x = 42;", "language": "ts", "title": "example.ts" }
{ "kind": "code", "code": "...", "language": "ts", "title": "x.ts", "lineStart": 80 }
{ "kind": "html", "html": "<ul class=\"tree\">...</ul>", "kits": ["issues"] } # opt into a kit (see Kits)
Expand Down Expand Up @@ -160,6 +163,7 @@ sideshow markdown plan.md --title "Migration plan" # markdown
sideshow mermaid flow.mmd --title "Request flow" # mermaid
sideshow diff change.patch --layout split --title "..." # diff
sideshow json data.json --title "API response" # json (collapsible tree)
sideshow json data.json --open-depth 2 --title "Nested" # json (first 2 levels open)
sideshow code app.ts --title "Entry point" # code (lang inferred from filename)
sideshow code - --language python --title "Script" # code from stdin
sideshow code app.ts --line-start 80 --title "app.ts" # excerpt with original line numbers
Expand Down
10 changes: 8 additions & 2 deletions server/postSurfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,24 +195,30 @@ const looseTerminalPart = z.object({
// drops the surface if `data` is absent. The transform fixes zod's inference:
// z.unknown() marks the key optional, but data is always present after the
// refine, so the output type must be { kind: "json"; data: unknown }.
// `openDepth` optionally expands containers up to that depth on render.
function maybeOpenDepth(n: number | undefined): { openDepth?: number } {
return typeof n === "number" && Number.isFinite(n) && n >= 0 ? { openDepth: Math.floor(n) } : {};
}
const strictJsonPart = z
.object({
kind: z.literal("json"),
data: z.unknown(),
openDepth: z.number().int().min(0).optional(),
})
.refine((p) => p.data !== undefined, {
message: 'json surface requires "data"',
})
.transform((p) => ({ kind: "json" as const, data: p.data }));
.transform((p) => ({ kind: "json" as const, data: p.data, ...maybeOpenDepth(p.openDepth) }));
const looseJsonPart = z
.object({
kind: z.literal("json"),
data: z.unknown(),
openDepth: optionalLooseNumber,
})
.refine((p) => p.data !== undefined, {
message: 'json surface requires "data"',
})
.transform((p) => ({ kind: "json" as const, data: p.data }));
.transform((p) => ({ kind: "json" as const, data: p.data, ...maybeOpenDepth(p.openDepth) }));

const strictCodePart = z.object({
kind: z.literal("code"),
Expand Down
6 changes: 5 additions & 1 deletion server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ export interface TerminalSurface {
export interface JsonSurface {
kind: "json";
data: unknown;
// How many levels of containers to expand on initial render (root is depth
// 0). Default 0 = only the root object/array is open. Set to e.g. 2 to
// reveal the first two levels of nested structure at a glance.
openDepth?: number;
}

// A code surface is source code the trusted viewer highlights with shiki (the
Expand Down Expand Up @@ -415,7 +419,7 @@ export function surfacesByteLength(surfaces: Surface[]): number {
} else if (p.kind === "mermaid") {
n += p.mermaid.length;
} else if (p.kind === "json") {
n += JSON.stringify(p.data).length;
n += JSON.stringify(p.data).length + (p.openDepth ? 4 : 0);
} else if (p.kind === "code") {
n +=
p.code.length + (p.language?.length ?? 0) + (p.title?.length ?? 0) + (p.lineStart ? 4 : 0);
Expand Down
12 changes: 12 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,18 @@ test("json part without data key is rejected", async () => {
assert.equal(res.status, 400);
});

test("json part openDepth round-trips", async () => {
const app = makeApp();
const res = await app.request(
"/api/surfaces",
json({ title: "Tree", parts: [{ kind: "json", data: { a: { b: 1 } }, openDepth: 2 }] }),
);
assert.equal(res.status, 201);
const surface = (await res.json()) as any;
const full = (await (await app.request(`/api/surfaces/${surface.id}`)).json()) as any;
assert.equal(full.surfaces[0].openDepth, 2);
});

test("publishes a code part; round-trips code/lang/title and 404s on /s", async () => {
const app = makeApp();
const res = await app.request(
Expand Down
2 changes: 1 addition & 1 deletion test/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ test("validateSurfaces accepts all supported part kinds", async () => {
{ kind: "trace", steps: [{ label: "read", kind: "tool" }], title: "Trace" },
{ kind: "trace", assetId: "trace-file" },
{ kind: "json", data: { a: 1, b: [true, null, "hi"] } },
{ kind: "json", data: null },
{ kind: "json", data: null, openDepth: 2 },
{ kind: "json", data: 42 },
{ kind: "code", code: "const x = 1;", language: "ts", title: "a.ts" },
{ kind: "code", code: "print('hi')" },
Expand Down
13 changes: 7 additions & 6 deletions viewer/src/JsonSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,28 @@ import { createSignal, For, Show } from "solid-js";
import type { JsonSurface as JsonSurfaceData } from "./api.ts";

export function JsonSurface(props: { surface: JsonSurfaceData }) {
const openDepth = () => props.surface.openDepth ?? 0;
return (
<div class="json-surface">
<JsonNode value={props.surface.data} depth={0} />
<JsonNode value={props.surface.data} depth={0} openDepth={openDepth()} />
</div>
);
}

function JsonNode(props: { value: unknown; depth: number }) {
function JsonNode(props: { value: unknown; depth: number; openDepth: number }) {
const isContainer = () => typeof props.value === "object" && props.value !== null;

return (
<Show when={isContainer()} fallback={<Primitive value={props.value} />}>
<Container value={props.value as object} depth={props.depth} />
<Container value={props.value as object} depth={props.depth} openDepth={props.openDepth} />
</Show>
);
}

type Entry = readonly [string, unknown];

function Container(props: { value: object; depth: number }) {
const [open, setOpen] = createSignal(props.depth === 0);
function Container(props: { value: object; depth: number; openDepth: number }) {
const [open, setOpen] = createSignal(props.depth <= props.openDepth);
const isArray = () => Array.isArray(props.value);
const entries = (): Entry[] =>
isArray()
Expand Down Expand Up @@ -66,7 +67,7 @@ function Container(props: { value: object; depth: number }) {
<span class="json-key">"{key}"</span>
<span class="json-colon">: </span>
</Show>
<JsonNode value={val} depth={props.depth + 1} />
<JsonNode value={val} depth={props.depth + 1} openDepth={props.openDepth} />
<Show when={i() < entries().length - 1}>
<span class="json-comma">,</span>
</Show>
Expand Down
Loading