diff --git a/.pi/extensions/pi-web.ts b/.pi/extensions/pi-web.ts index 65111812..b2ccd2db 100644 --- a/.pi/extensions/pi-web.ts +++ b/.pi/extensions/pi-web.ts @@ -798,6 +798,8 @@ async function showRemoteAccess( export default function (pi: ExtensionAPI) { let lastAutoTitle: string | null = null; + let titleJobId = 0; + pi.registerTool({ name: "pi_web_set_tab_title", label: "Set Tab Title", @@ -812,15 +814,49 @@ export default function (pi: ExtensionAPI) { title: Type.String({ description: "Short 2-5 word session title." }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const title = setPiWebTabTitle(pi, ctx, String(params.title ?? "")); - lastAutoTitle = title; + const title = String(params.title ?? ""); + const backgroundTitleUpdates = + process.env["PI_WEB_BACKGROUND_TAB_TITLE"] === "1"; + + if (!backgroundTitleUpdates) { + const finalTitle = setPiWebTabTitle(pi, ctx, title); + lastAutoTitle = finalTitle; + return { + content: [{ type: "text", text: `Session title set to ${finalTitle}.` }], + details: { title: finalTitle }, + }; + } + + const jobId = ++titleJobId; + const sessionFile = ctx.sessionManager.getSessionFile(); + + void (async () => { + try { + if (jobId !== titleJobId) return; + if (ctx.sessionManager.getSessionFile() !== sessionFile) return; + + const finalTitle = setPiWebTabTitle(pi, ctx, title); + lastAutoTitle = finalTitle; + if (ctx.hasUI) { + ctx.ui.notify(`Session title set to ${finalTitle}.`, "info"); + } + } catch (error) { + console.warn("[pi-web] background tab title update failed", error); + } + })(); + return { - content: [{ type: "text", text: `Session title set to ${title}.` }], - details: { title }, + content: [{ type: "text", text: "Session title update queued." }], + details: { queued: true, title }, }; }, }); + pi.on("session_shutdown", () => { + // Cancel any queued background title update before reload/session replacement. + titleJobId++; + }); + pi.on("input", async (event, ctx) => { const title = typeof event.text === "string" ? deriveTitleFromInput(event.text) : null; diff --git a/README.md b/README.md index f534683f..31a9d766 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ After `pi install npm:@ygncode/pi-web`, you get: | `/pi-web` | Show status, version, start/stop/restart the server, or update | | `/remote` | Show a QR code and URL for remote access over Tailscale | | `/refresh` | Pull new messages written from remote browsers back into the terminal session | -| `set_tab_title` | Tool that updates the session title; also auto‑derives a short title from each user message | +| `set_tab_title` | Tool that updates the session title; also auto‑derives a short title from each user message. Set `PI_WEB_BACKGROUND_TAB_TITLE=1` to queue title updates in the background. | The package also installs the pi-web binary to `~/.pi/agent/bin/pi-web` and sets up auto-start on login. @@ -81,7 +81,7 @@ To set a token for remote access, create `~/.config/pi-web/env`: PI_WEB_TOKEN=your-token-here ``` -For more details (manual setup, custom ports, non-loopback binds), see [docs/install.md](docs/install.md). +For more details (manual setup, custom ports, non-loopback binds, optional extension settings), see [docs/install.md](docs/install.md). ## Development diff --git a/docs/install.md b/docs/install.md index e6a51807..14834b5d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -129,6 +129,15 @@ PI_WEB_TOKEN=$(openssl rand -hex 16) pi-web --host 192.168.1.50 By default, pi-web binds to `127.0.0.1`. If Tailscale is running with MagicDNS, pi-web also runs `tailscale serve --bg --https= http://127.0.0.1:` and prints the HTTPS tailnet URL. Any explicit non-loopback bind requires `PI_WEB_TOKEN` to be set; pass `--insecure` to override for local testing. +## Optional Environment Settings + +Optional extension settings can be added to `~/.config/pi-web/env`: + +```bash +# Queue tab title updates in the background instead of blocking the agent turn. +PI_WEB_BACKGROUND_TAB_TITLE=1 +``` + ## Remote Access Leave pi-web listening locally, then use the printed Tailscale HTTPS URL from your phone or laptop on the tailnet. diff --git a/tests/extensions/pi-web.test.ts b/tests/extensions/pi-web.test.ts index d62b087d..0c8c36fb 100644 --- a/tests/extensions/pi-web.test.ts +++ b/tests/extensions/pi-web.test.ts @@ -36,7 +36,7 @@ vi.mock('node:fs', async (importOriginal) => { }; }); -import { +import piWebExtension, { isTailscaleHost, isSSH, normalizeCommandArgs, @@ -54,6 +54,37 @@ declare global { var __MOCK_PI_WEB_ENV_CONTENT__: string | undefined; } +function createExtensionHarness() { + const tools = new Map(); + const handlers = new Map(); + const pi = { + registerTool: vi.fn((tool: any) => tools.set(tool.name, tool)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: Function) => { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }), + setSessionName: vi.fn(), + exec: vi.fn(), + }; + + piWebExtension(pi as any); + + const ctx = { + hasUI: true, + ui: { + setTitle: vi.fn(), + notify: vi.fn(), + }, + sessionManager: { + getSessionFile: vi.fn(() => '/tmp/pi-session.jsonl'), + }, + }; + + return { pi, ctx, tools, handlers }; +} + // ── isSSH ─────────────────────────────────────────────────────────── describe('isSSH', () => { const orig = { ...process.env }; @@ -212,6 +243,65 @@ describe('deriveTitleFromInput', () => { }); }); +// ── pi_web_set_tab_title tool ─────────────────────────────────────── +describe('pi_web_set_tab_title tool', () => { + const orig = { ...process.env }; + + afterEach(() => { + process.env = { ...orig }; + vi.restoreAllMocks(); + }); + + it('keeps the default synchronous response shape', async () => { + delete process.env.PI_WEB_BACKGROUND_TAB_TITLE; + const { pi, ctx, tools } = createExtensionHarness(); + const tool = tools.get('pi_web_set_tab_title'); + + expect(tool).toBeDefined(); + const result = await tool.execute( + 'call-1', + { title: ' Test Session ' }, + undefined, + undefined, + ctx, + ); + + expect(ctx.ui.setTitle).toHaveBeenCalledWith('Test Session'); + expect(pi.setSessionName).toHaveBeenCalledWith('Test Session'); + expect(ctx.ui.notify).not.toHaveBeenCalled(); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Session title set to Test Session.' }], + details: { title: 'Test Session' }, + }); + }); + + it('queues title updates when PI_WEB_BACKGROUND_TAB_TITLE is set', async () => { + process.env.PI_WEB_BACKGROUND_TAB_TITLE = '1'; + const { pi, ctx, tools } = createExtensionHarness(); + const tool = tools.get('pi_web_set_tab_title'); + + expect(tool).toBeDefined(); + const result = await tool.execute( + 'call-1', + { title: 'Background Session' }, + undefined, + undefined, + ctx, + ); + + expect(ctx.ui.setTitle).toHaveBeenCalledWith('Background Session'); + expect(pi.setSessionName).toHaveBeenCalledWith('Background Session'); + expect(ctx.ui.notify).toHaveBeenCalledWith( + 'Session title set to Background Session.', + 'info', + ); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Session title update queued.' }], + details: { queued: true, title: 'Background Session' }, + }); + }); +}); + // ── withToken / readPiWebToken ────────────────────────────────────── describe('token helpers', () => { beforeEach(() => {