Skip to content
Closed
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
44 changes: 40 additions & 4 deletions .pi/extensions/pi-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
9 changes: 9 additions & 0 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<port> http://127.0.0.1:<port>` 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.
Expand Down
92 changes: 91 additions & 1 deletion tests/extensions/pi-web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ vi.mock('node:fs', async (importOriginal) => {
};
});

import {
import piWebExtension, {
isTailscaleHost,
isSSH,
normalizeCommandArgs,
Expand All @@ -54,6 +54,37 @@ declare global {
var __MOCK_PI_WEB_ENV_CONTENT__: string | undefined;
}

function createExtensionHarness() {
const tools = new Map<string, any>();
const handlers = new Map<string, Function[]>();
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 };
Expand Down Expand Up @@ -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(() => {
Expand Down