diff --git a/.changeset/quiet-pandas-watch.md b/.changeset/quiet-pandas-watch.md new file mode 100644 index 0000000..1193f04 --- /dev/null +++ b/.changeset/quiet-pandas-watch.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Keep Pi's TUI quiet until the current conversation connects to a Sideshow session, keep the status current when tools or conversation-tree navigation switch sessions, and allow hiding it entirely with `SIDESHOW_TUI_STATUS=0` without disabling native tools or trace synchronization. diff --git a/docs/connecting-agents.md b/docs/connecting-agents.md index 77fb0ae..5bbb8b8 100644 --- a/docs/connecting-agents.md +++ b/docs/connecting-agents.md @@ -41,6 +41,12 @@ pi install npm:sideshow pi -e npm:sideshow ``` +After the current conversation connects to a Sideshow session, the extension +shows that session in Pi's TUI status area and keeps it current when tools or +conversation-tree navigation switch sessions. Idle conversations stay quiet. Set `SIDESHOW_TUI_STATUS=0` before +launching Pi to hide the status entirely without disabling the native tools or +automatic trace synchronization. + ## MCP Tools: `publish_post`, `update_post`, `list_posts`, `get_post`, diff --git a/extensions/sideshow.js b/extensions/sideshow.js index ade1cf2..5a17797 100644 --- a/extensions/sideshow.js +++ b/extensions/sideshow.js @@ -26,6 +26,13 @@ const CONTENT_TYPES = { const feedbackGuideline = "Sideshow tool results may include userFeedback from browser comments; treat it as user instruction and respond or update the surface."; +const SESSION_REMEMBERING_TOOLS = new Set([ + "sideshow_publish_surface", + "sideshow_update_surface", + "sideshow_reply_to_user", + "sideshow_upload_asset", +]); + const partSchema = { type: "object", properties: { @@ -104,6 +111,10 @@ function agentName() { return process.env.SIDESHOW_AGENT || "pi"; } +function showTuiStatus() { + return process.env.SIDESHOW_TUI_STATUS !== "0"; +} + function authHeaders(extra = {}) { return { ...(process.env.SIDESHOW_TOKEN @@ -182,8 +193,17 @@ async function requestText(path) { return text; } -function rememberSession(state, sessionId) { - if (typeof sessionId === "string" && sessionId) state.sessionId = sessionId; +function updateTuiStatus(state, ctx) { + ctx.ui.setStatus( + "sideshow", + showTuiStatus() && state.sessionId ? `sideshow ${state.sessionId}` : undefined, + ); +} + +function rememberSession(state, sessionId, ctx) { + if (typeof sessionId !== "string" || !sessionId) return; + state.sessionId = sessionId; + updateTuiStatus(state, ctx); } function urlForSurface(surfaceId) { @@ -363,7 +383,7 @@ function reconstructSession(ctx) { for (const entry of ctx.sessionManager.getBranch()) { const message = entry?.type === "message" ? entry.message : undefined; if (message?.role !== "toolResult") continue; - if (!String(message.toolName ?? "").startsWith("sideshow_")) continue; + if (!SESSION_REMEMBERING_TOOLS.has(message.toolName)) continue; const details = message.details; if (details && typeof details.sessionId === "string") sessionId = details.sessionId; if (details?.surface && typeof details.surface.sessionId === "string") @@ -377,15 +397,13 @@ function reconstructSession(ctx) { export default function sideshowExtension(pi) { const state = { sessionId: process.env.SIDESHOW_SESSION || undefined }; - pi.on("session_start", (_event, ctx) => { + const restoreSession = (ctx) => { state.sessionId = reconstructSession(ctx); - ctx.ui.setStatus( - "sideshow", - state.sessionId - ? `sideshow ${state.sessionId}` - : `sideshow ${baseUrl().replace(/^https?:\/\//, "")}`, - ); - }); + updateTuiStatus(state, ctx); + }; + + pi.on("session_start", (_event, ctx) => restoreSession(ctx)); + pi.on("session_tree", (_event, ctx) => restoreSession(ctx)); pi.on("turn_end", async (_event, ctx) => { if (!state.sessionId) return; @@ -403,7 +421,7 @@ export default function sideshowExtension(pi) { const command = args.trim(); if (command === "reset") { state.sessionId = process.env.SIDESHOW_SESSION || undefined; - ctx.ui.setStatus("sideshow", `sideshow ${baseUrl().replace(/^https?:\/\//, "")}`); + updateTuiStatus(state, ctx); ctx.ui.notify("Reset remembered sideshow session", "info"); return; } @@ -498,7 +516,7 @@ export default function sideshowExtension(pi) { method: "POST", body: JSON.stringify(body), }); - rememberSession(state, surface.sessionId); + rememberSession(state, surface.sessionId, ctx); const url = urlForSurface(surface.id); return { content: [ @@ -531,13 +549,13 @@ export default function sideshowExtension(pi) { }, required: ["id"], }, - async execute(_toolCallId, params) { + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const body = { title: params.title, parts: params.parts }; const surface = await requestJson(`/api/surfaces/${encodeURIComponent(params.id)}`, { method: "PUT", body: JSON.stringify(body), }); - rememberSession(state, surface.sessionId); + rememberSession(state, surface.sessionId, ctx); const url = urlForSurface(surface.id); return { content: [ @@ -614,7 +632,7 @@ export default function sideshowExtension(pi) { }, required: ["message"], }, - async execute(_toolCallId, params) { + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const body = { text: params.message, surface: params.surfaceId, @@ -625,7 +643,7 @@ export default function sideshowExtension(pi) { method: "POST", body: JSON.stringify(body), }); - rememberSession(state, comment.sessionId); + rememberSession(state, comment.sessionId, ctx); return { content: [ { @@ -764,7 +782,7 @@ export default function sideshowExtension(pi) { body: JSON.stringify({ agent: agentName(), title: params.sessionTitle, cwd: ctx.cwd }), }); session = created.id; - rememberSession(state, session); + rememberSession(state, session, ctx); } const query = new URLSearchParams(); @@ -778,7 +796,7 @@ export default function sideshowExtension(pi) { headers: { "content-type": contentType }, body: bytes, }); - rememberSession(state, asset.sessionId); + rememberSession(state, asset.sessionId, ctx); return { content: [ { diff --git a/package.json b/package.json index 991083f..e3a8fad 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,7 @@ "packageManager": "npm@11.17.0", "pi": { "extensions": [ - "./extensions" + "./extensions/sideshow.js" ], "skills": [ "./skills" diff --git a/test/piExtension.test.ts b/test/piExtension.test.ts new file mode 100644 index 0000000..3f31d1e --- /dev/null +++ b/test/piExtension.test.ts @@ -0,0 +1,284 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; + +import sideshowExtension from "../extensions/sideshow.js"; + +const originalTuiStatus = process.env.SIDESHOW_TUI_STATUS; +const originalSession = process.env.SIDESHOW_SESSION; + +type Status = [key: string, text: string | undefined]; +type BranchEntry = { + type: "message"; + message: { + role: "toolResult"; + toolName: string; + details?: { sessionId?: string }; + }; +}; +interface Context { + cwd: string; + sessionManager: { getBranch: () => BranchEntry[] }; + ui: { + setStatus: (key: string, text: string | undefined) => void; + notify: () => void; + }; +} +type Handler = (event: object, context: Context) => unknown; +type Command = { handler: (args: string, context: Context) => Promise }; +type Tool = { name: string; execute: (...args: unknown[]) => Promise }; + +afterEach(() => { + if (originalTuiStatus === undefined) delete process.env.SIDESHOW_TUI_STATUS; + else process.env.SIDESHOW_TUI_STATUS = originalTuiStatus; + if (originalSession === undefined) delete process.env.SIDESHOW_SESSION; + else process.env.SIDESHOW_SESSION = originalSession; +}); + +function loadExtension() { + const handlers = new Map(); + const commands = new Map(); + const tools = new Map(); + const pi = { + on(event: string, handler: Handler) { + handlers.set(event, handler); + }, + registerCommand(name: string, command: Command) { + commands.set(name, command); + }, + registerTool(tool: Tool) { + tools.set(tool.name, tool); + }, + }; + sideshowExtension(pi); + return { handlers, commands, tools }; +} + +function context(statuses: Status[], branch: BranchEntry[] = []): Context { + return { + cwd: "/tmp/sideshow-extension-test", + sessionManager: { getBranch: () => branch }, + ui: { + setStatus(key, text) { + statuses.push([key, text]); + }, + notify() {}, + }, + }; +} + +function sessionEntry(sessionId: string, toolName = "sideshow_publish_surface"): BranchEntry { + return { + type: "message", + message: { + role: "toolResult", + toolName, + details: { sessionId }, + }, + }; +} + +describe("Pi extension TUI status", () => { + it("stays hidden until the conversation has a Sideshow session", () => { + delete process.env.SIDESHOW_SESSION; + delete process.env.SIDESHOW_TUI_STATUS; + const { handlers } = loadExtension(); + const idleStatuses: Status[] = []; + const activeStatuses: Status[] = []; + + handlers.get("session_start")!({}, context(idleStatuses)); + handlers.get("session_start")!({}, context(activeStatuses, [sessionEntry("session-a")])); + + assert.deepEqual(idleStatuses, [["sideshow", undefined]]); + assert.deepEqual(activeStatuses, [["sideshow", "sideshow session-a"]]); + }); + + it("does not restore explicit sessions used only by read tools", () => { + delete process.env.SIDESHOW_SESSION; + delete process.env.SIDESHOW_TUI_STATUS; + const { handlers } = loadExtension(); + const statuses: Status[] = []; + const branch = [ + sessionEntry("active-session"), + sessionEntry("wait-only-session", "sideshow_wait_for_feedback"), + sessionEntry("list-only-session", "sideshow_list_surfaces"), + ]; + + handlers.get("session_start")!({}, context(statuses, branch)); + + assert.deepEqual(statuses, [["sideshow", "sideshow active-session"]]); + }); + + it("follows Sideshow sessions across conversation tree branches", () => { + delete process.env.SIDESHOW_SESSION; + delete process.env.SIDESHOW_TUI_STATUS; + const { handlers } = loadExtension(); + const statuses: Status[] = []; + + handlers.get("session_start")!({}, context(statuses, [sessionEntry("session-a")])); + handlers.get("session_tree")!({}, context(statuses, [sessionEntry("session-b")])); + handlers.get("session_tree")!({}, context(statuses)); + + assert.deepEqual(statuses, [ + ["sideshow", "sideshow session-a"], + ["sideshow", "sideshow session-b"], + ["sideshow", undefined], + ]); + }); + + it("resets a restored session to SIDESHOW_SESSION", async () => { + delete process.env.SIDESHOW_TUI_STATUS; + process.env.SIDESHOW_SESSION = "configured-session"; + const { commands, handlers } = loadExtension(); + const statuses: Status[] = []; + const ctx = context(statuses, [sessionEntry("restored-session")]); + + handlers.get("session_start")!({}, ctx); + await commands.get("sideshow")!.handler("reset", ctx); + + assert.deepEqual(statuses, [ + ["sideshow", "sideshow restored-session"], + ["sideshow", "sideshow configured-session"], + ]); + }); + + it("tracks the session returned by each publishing tool call and clears on reset", async (t) => { + delete process.env.SIDESHOW_SESSION; + delete process.env.SIDESHOW_TUI_STATUS; + const returnedSessions = ["session-a", "session-b"]; + t.mock.method(globalThis, "fetch", async () => { + const sessionId = returnedSessions.shift(); + return new Response( + JSON.stringify({ id: `post-${sessionId}`, title: "Status test", sessionId }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + }); + + const { commands, handlers, tools } = loadExtension(); + const statuses: Status[] = []; + const ctx = context(statuses); + const publish = tools.get("sideshow_publish_surface")!; + + handlers.get("session_start")!({}, ctx); + await publish.execute( + "call-a", + { title: "Status test", parts: [{ kind: "markdown", markdown: "A" }], newSession: true }, + undefined, + undefined, + ctx, + ); + await publish.execute( + "call-b", + { title: "Status test", parts: [{ kind: "markdown", markdown: "B" }], newSession: true }, + undefined, + undefined, + ctx, + ); + await commands.get("sideshow")!.handler("reset", ctx); + + assert.deepEqual(statuses, [ + ["sideshow", undefined], + ["sideshow", "sideshow session-a"], + ["sideshow", "sideshow session-b"], + ["sideshow", undefined], + ]); + }); + + it("tracks sessions returned by update, reply, and upload tools", async (t) => { + delete process.env.SIDESHOW_SESSION; + delete process.env.SIDESHOW_TUI_STATUS; + t.mock.method(globalThis, "fetch", async (input: string | URL | Request) => { + const url = String(input); + let body; + if (url.includes("/api/surfaces/post-a")) { + body = { + id: "post-a", + title: "Updated", + version: 2, + sessionId: "update-session", + }; + } else if (url.includes("/api/comments")) { + body = { id: "comment-a", sessionId: "reply-session" }; + } else { + body = { + id: "asset-a", + contentType: "text/plain", + byteLength: 1, + url: "/a/asset-a", + sessionId: "upload-session", + }; + } + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const { handlers, tools } = loadExtension(); + const statuses: Status[] = []; + const ctx = context(statuses); + handlers.get("session_start")!({}, ctx); + + await tools + .get("sideshow_update_surface")! + .execute("call-update", { id: "post-a", title: "Updated" }, undefined, undefined, ctx); + await tools + .get("sideshow_reply_to_user")! + .execute( + "call-reply", + { message: "Done", session: "reply-session" }, + undefined, + undefined, + ctx, + ); + await tools + .get("sideshow_upload_asset")! + .execute("call-upload", { data: "eA==", filename: "x.txt" }, undefined, undefined, ctx); + + assert.deepEqual(statuses, [ + ["sideshow", undefined], + ["sideshow", "sideshow update-session"], + ["sideshow", "sideshow reply-session"], + ["sideshow", "sideshow upload-session"], + ]); + }); + + it("keeps status hidden with SIDESHOW_TUI_STATUS=0", async (t) => { + process.env.SIDESHOW_TUI_STATUS = "0"; + process.env.SIDESHOW_SESSION = "configured-session"; + t.mock.method( + globalThis, + "fetch", + async () => + new Response( + JSON.stringify({ id: "post-hidden", title: "Hidden status", sessionId: "new-session" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + const { handlers, commands, tools } = loadExtension(); + const statuses: Status[] = []; + const ctx = context(statuses, [sessionEntry("restored-session")]); + + handlers.get("session_start")!({}, ctx); + await tools.get("sideshow_publish_surface")!.execute( + "call-hidden", + { + title: "Hidden status", + parts: [{ kind: "markdown", markdown: "Hidden" }], + newSession: true, + }, + undefined, + undefined, + ctx, + ); + await commands.get("sideshow")!.handler("reset", ctx); + + assert.deepEqual(statuses, [ + ["sideshow", undefined], + ["sideshow", undefined], + ["sideshow", undefined], + ]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 7562917..b2e65d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "nodenext", "types": ["node"], "strict": true, + "allowJs": true, "noEmit": true, "allowImportingTsExtensions": true, "erasableSyntaxOnly": true,