From 59167d7af378adb84f2fe6fd825f76791906066f Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 11 Oct 2026 04:36:41 +0000 Subject: [PATCH 1/3] feat(pipes): add page to explain-pipe and a record-pipes agent tool explain-pipe merged the live pipe data of every open tab, so with two tabs an agent saw summed instance and call counts. It now takes the standard `page` argument, answers from the named tab or the most recent one, names the tab it read when several report, and answers an unknown page with the list of tabs that do report pipes. Agents could not start pipe call recording, so explain-pipe could only suggest clicking Record calls in the panel. record-pipes starts or stops recording through the same request-instrument-pipes path the panel uses. Like change detection recording and router instrumentation it is not a write action: it stays in read-only mode and is hidden with agent.tools.pipes or the pipes inspector. lint-pipes reads source only, so it takes no page. --- apps/docs/src/content/agents/tools.md | 26 +++- apps/docs/src/content/inspectors/pipes.md | 5 +- .../devtools/src/__tests__/config.test.ts | 16 ++ .../src/__tests__/docs-tool-count.test.ts | 1 + .../src/__tests__/pipes-pages.test.ts | 147 ++++++++++++++++-- packages/devtools/src/config.ts | 2 + packages/devtools/src/devframe.ts | 54 ++++--- .../src/rpc/__tests__/pipe-explain.test.ts | 6 +- packages/devtools/src/rpc/pipe-explain.ts | 2 +- packages/devtools/src/rpc/pipes-agent.ts | 77 +++++++++ 10 files changed, 294 insertions(+), 42 deletions(-) create mode 100644 packages/devtools/src/rpc/pipes-agent.ts diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 7d1eee05..eff4888e 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -4,7 +4,7 @@ description: Every agent tool the devtools expose, grouped by inspector, with wh --- - Fifty-five tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. + Fifty-six tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. # Tools @@ -339,16 +339,26 @@ It finds impure pipes used inside `@for`, `| json` left in templates, pure pipes | -------- | -------- | ----------------------------------------------- | | `name` | yes | The pipe name as used after `\|` in a template. | -Live counts, input and output appear when recording is on in the [Pipes inspector](../inspectors/pipes.md). +Live data comes from one tab: the one `page` names, or the most recent. When more than one tab reports pipes, the answer starts with the tab it read. Live counts, input and output appear while recording is on. Start it with [`record-pipes`](#record-pipe-calls) or with **Record calls** in the [Pipes inspector](../inspectors/pipes.md). The **Live** line tells these cases apart: -| Case | What the tool says | -| ----------------------------------- | --------------------------------------------------------------------- | -| No page is connected | There is no live data. The stdio server always reports this. | -| A page is connected, pipe not used | No rendered component on the page uses the pipe. | -| Pipe in use, recording off | Instance count and components, plus a hint to click **Record calls**. | -| Pipe in use, recording on, no calls | Instance count and components, and that no calls are recorded yet. | +| Case | What the tool says | +| ----------------------------------- | ------------------------------------------------------------------ | +| No page is connected | There is no live data. The stdio server always reports this. | +| A page is connected, pipe not used | No rendered component on the page uses the pipe. | +| Pipe in use, recording off | Instance count and components, plus a hint to start recording. | +| Pipe in use, recording on, no calls | Instance count and components, and that no calls are recorded yet. | + +### Record pipe calls + +`record-pipes` starts or stops call recording on a page, the same switch as **Record calls** in the panel. Reads: page. + +| Argument | Required | Value | +| -------- | -------- | ------------------ | +| `action` | yes | `start` or `stop`. | + +`start` records on the tab `page` names, or on the most recent tab. Other tabs stay unpatched. `stop` stops that tab, or every tab without `page`. Each recording starts from zero, and the calls recorded so far stay after a stop. Call `record-pipes` with `action: "start"`, use the app, then call `explain-pipe`. Recording is instrumentation, like the `record` argument of `change-detection`, so `agent.readOnly` and `actions` leave the tool in place. `agent.tools.pipes: false` hides it. ## SSR & HTTP diff --git a/apps/docs/src/content/inspectors/pipes.md b/apps/docs/src/content/inspectors/pipes.md index 825ed814..6ff7463c 100644 --- a/apps/docs/src/content/inspectors/pipes.md +++ b/apps/docs/src/content/inspectors/pipes.md @@ -129,8 +129,9 @@ Look for **resubscribing** rows in **Async subscriptions**, and for `async-on-ca | `pangular:get-pipes` | no | Custom pipes and the built-in pipes in use. | | `pangular:lint-pipes` | no | Runs the lint rules above. | | `pangular:explain-pipe` | partly | One pipe by `name`: where it is declared or used, purity, live counts, last input and output, the stale warning, resubscribing `\| async` usages and the lint findings. | +| `pangular:record-pipes` | yes | Starts or stops call recording with `action`, like **Record calls**. | -Agents can't turn recording on. To give `explain-pipe` call data, click **Record calls** in the panel first. See [Tools](../agents/tools.md). +`explain-pipe` and `record-pipes` take `page` to pick a tab, and default to the most recent one. To give `explain-pipe` call data, call `record-pipes` with `action: "start"` or click **Record calls** in the panel, then use the app. See [Tools](../agents/tools.md#pipes). ## Limits and gotchas @@ -154,7 +155,7 @@ Recording is off by default. Reload the page and it is off again. - Recording is off. Agents can't turn it on. Click Record calls in the panel first. + Recording is off. Call pangular:record-pipes with action: "start", or click Record calls in the panel, then use the app. The built-in list covers the @angular/common pipes only. diff --git a/packages/devtools/src/__tests__/config.test.ts b/packages/devtools/src/__tests__/config.test.ts index 0cd54741..fcce13de 100644 --- a/packages/devtools/src/__tests__/config.test.ts +++ b/packages/devtools/src/__tests__/config.test.ts @@ -271,6 +271,22 @@ describe('agent tool and RPC registration', () => { expect(tools).toContain('list-routes'); }); + it('keeps record-pipes with its inspector, not behind a write action', async () => { + const pipeTools = ['explain-pipe', 'record-pipes']; + expect((await boot()).tools).toEqual(expect.arrayContaining(pipeTools)); + // Recording is instrumentation, like change detection recording, so + // blocking actions or read-only mode leaves it in place. + const blocked = await boot({ actions: false, agent: { readOnly: true } }); + expect(blocked.tools).toEqual(expect.arrayContaining(pipeTools)); + expect(blocked.actionTools).not.toContain('record-pipes'); + const hidden = await boot({ agent: { tools: { pipes: false } } }); + expect(hidden.tools.filter((id) => pipeTools.includes(id))).toEqual([]); + expect(hidden.rpc).toContain('request-instrument-pipes'); + const off = await boot({ inspectors: { pipes: false } }); + expect(off.tools.filter((id) => pipeTools.includes(id))).toEqual([]); + expect(off.rpc).not.toContain('request-instrument-pipes'); + }); + it('hides one inspector from the agent while its panel keeps working', async () => { const { rpc, tools, resources } = await boot({ agent: { tools: { router: false } } }); expect(tools.some((id) => AGENT_INSPECTOR[id] === 'router')).toBe(false); diff --git a/packages/devtools/src/__tests__/docs-tool-count.test.ts b/packages/devtools/src/__tests__/docs-tool-count.test.ts index bb59f23e..0efebee2 100644 --- a/packages/devtools/src/__tests__/docs-tool-count.test.ts +++ b/packages/devtools/src/__tests__/docs-tool-count.test.ts @@ -13,6 +13,7 @@ const WORDS = [ 'Fifty-three', 'Fifty-four', 'Fifty-five', + 'Fifty-six', ]; it('states on the tools page how many tools the server registers', async () => { diff --git a/packages/devtools/src/__tests__/pipes-pages.test.ts b/packages/devtools/src/__tests__/pipes-pages.test.ts index ca941b82..4eadf811 100644 --- a/packages/devtools/src/__tests__/pipes-pages.test.ts +++ b/packages/devtools/src/__tests__/pipes-pages.test.ts @@ -2,6 +2,7 @@ import { createHostContext } from 'devframe/node'; import { afterEach, describe, expect, it, vi } from 'vitest'; import pangular from '../devframe.ts'; import { isPipePageReport, mergePipePageReport, type PipePageReport } from '../rpc/pipes-tools.ts'; +import { fixtureDir } from '../rpc/__tests__/fixture-dir.ts'; async function boot() { const host = { @@ -9,17 +10,17 @@ async function boot() { resolveOrigin: () => 'http://localhost', getStorageDir: () => '', }; - const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: host as never }); + // An empty project: explain-pipe scans the source on every call, and these + // tests are about live data only. + const cwd = fixtureDir('pangular-pipes-pages-'); + const ctx = await createHostContext({ cwd, mode: 'dev', host: host as never }); await pangular.setup(ctx as never); const invoke = (name: string, ...args: unknown[]) => ctx.rpc.invokeLocal(`pangular:${name}` as never, ...(args as never)); - const explain = async () => - ( - (await ctx.agent.invoke('pangular:explain-pipe', { name: 'async' })) as { - markdown: string; - } - ).markdown; - return { ctx, invoke, explain }; + const tool = async (id: string, args: Record) => + ((await ctx.agent.invoke(`pangular:${id}`, args)) as { markdown: string }).markdown; + const explain = (page?: string) => tool('explain-pipe', { name: 'async', page }); + return { ctx, invoke, explain, tool }; } function page(pageId: string): PipePageReport { @@ -68,17 +69,18 @@ describe('pipe pages and their connections', () => { await invoke('push-pipes', page('tab-1')); host.getCurrentRpcSession = () => ({ meta: { id: 2 } }); await invoke('push-pipes', page('tab-2')); - expect(await explain()).toContain('6 instance(s)'); + expect(await explain('tab-1')).toContain('3 instance(s)'); host._emitSessionDisconnected({ id: 1 }); - expect(await explain()).toContain('3 instance(s)'); + expect(await explain('tab-1')).toMatch(/^No page `tab-1` is reporting pipe usage/); + expect(await explain('tab-2')).toContain('3 instance(s)'); // A reconnect re-binds the page to its new connection, so the old // connection closing afterwards must not drop it. host.getCurrentRpcSession = () => ({ meta: { id: 3 } }); await invoke('push-pipes', page('tab-2')); host._emitSessionDisconnected({ id: 2 }); - expect(await explain()).toContain('3 instance(s)'); + expect(await explain('tab-2')).toContain('3 instance(s)'); }); }); @@ -181,3 +183,126 @@ describe('pipe recording requests', () => { } }); }); + +function pageWith(pageId: string, instanceCount: number, callCount?: number): PipePageReport { + const base = page(pageId); + return { + ...base, + pipes: [ + { + ...base.pipes[0], + instanceCount, + components: [{ name: 'ExamplePage', count: instanceCount }], + ...(callCount === undefined + ? {} + : { call: { callCount, lastArgs: [pageId], lastResult: pageId } }), + }, + ], + instrumented: callCount !== undefined, + }; +} + +describe('explain-pipe page scoping', () => { + it('answers from the most recent page, or the one `page` names, without mixing tabs', async () => { + vi.useFakeTimers(); + try { + const { invoke, explain } = await boot(); + await invoke('push-pipes', pageWith('tab-1', 2, 5)); + await vi.advanceTimersByTimeAsync(1000); + await invoke('push-pipes', pageWith('tab-2', 4)); + + const latest = await explain(); + expect(latest).toMatch(/^Live data from page `tab-2`; 2 pages report pipe usage/); + expect(latest).toContain('4 instance(s)'); + expect(latest).toContain('recording is off'); + expect(latest).not.toContain('6 instance(s)'); + + const first = await explain('tab-1'); + expect(first).toContain('Live data from page `tab-1`'); + expect(first).toContain('2 instance(s)'); + expect(first).toContain('Called 5 time(s)'); + expect(first).toContain('Last input: `[tab-1]`'); + } finally { + vi.useRealTimers(); + } + }); + + it('names the reporting pages when `page` is unknown', async () => { + const { invoke, explain } = await boot(); + expect(await explain('gone')).toBe( + 'No page `gone` is reporting pipe usage. No page reports pipe usage right now.', + ); + await invoke('push-pipes', page('tab-1')); + expect(await explain('gone')).toBe( + 'No page `gone` is reporting pipe usage. Pages that report pipe usage: `tab-1`. Pass one of these ids as `page`, or leave `page` out for the most recent.', + ); + }); + + it('adds no page line when only one page reports', async () => { + const { invoke, explain } = await boot(); + await invoke('push-pipes', page('tab-1')); + const text = await explain(); + expect(text).not.toContain('Live data from page'); + expect(text).toContain('3 instance(s)'); + }); +}); + +describe('record-pipes agent tool', () => { + const recordings = (broadcast: { mock: { calls: unknown[][] } }) => + broadcast.mock.calls + .map(([options]) => options as { method: string; args: unknown[] }) + .filter((options) => options.method === 'pangular:instrument-pipes') + .map((options) => options.args[0]); + + it('starts and stops recording through the same request the panel sends', async () => { + vi.useFakeTimers(); + try { + const { ctx, invoke, tool } = await boot(); + const broadcast = vi.spyOn(ctx.rpc, 'broadcast'); + await invoke('push-pipes', page('tab-1')); + await vi.advanceTimersByTimeAsync(1000); + await invoke('push-pipes', page('tab-2')); + + expect(await tool('record-pipes', { action: 'start' })).toMatch( + /^Recording pipe calls on page `tab-2`\./, + ); + expect(await tool('record-pipes', { action: 'start', page: 'tab-1' })).toMatch( + /^Recording pipe calls on page `tab-1`\./, + ); + expect(await tool('record-pipes', { action: 'stop', page: 'tab-1' })).toMatch( + /^Recording stopped on page `tab-1`\./, + ); + expect(await tool('record-pipes', { action: 'stop' })).toMatch( + /^Recording stopped on 2 page\(s\)\./, + ); + expect(recordings(broadcast)).toEqual([ + { pageId: 'tab-2', on: true }, + { pageId: 'tab-1', on: true }, + { pageId: 'tab-1', on: false }, + { on: false }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('sends nothing for an unknown page, a missing action or before any page reports', async () => { + const { ctx, invoke, tool } = await boot(); + const broadcast = vi.spyOn(ctx.rpc, 'broadcast'); + expect(await tool('record-pipes', { action: 'start' })).toMatch( + /^No page reports pipe usage, so there is nothing to record/, + ); + await invoke('push-pipes', page('tab-1')); + expect(await tool('record-pipes', { action: 'start', page: 'gone' })).toMatch( + /^No page `gone` is reporting pipe usage\. Pages that report pipe usage: `tab-1`/, + ); + expect(await tool('record-pipes', {})).toBe('Pass `action`: "start" or "stop".'); + expect(recordings(broadcast)).toEqual([]); + }); + + it('points explain-pipe at the agent tool while recording is off', async () => { + const { invoke, explain } = await boot(); + await invoke('push-pipes', page('tab-1')); + expect(await explain()).toContain('Start it with pangular:record-pipes'); + }); +}); diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts index c3bf50c9..26c563af 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -530,6 +530,7 @@ export const AGENT_INSPECTOR: Record = { navigate: 'router', 'lint-pipes': 'pipes', 'explain-pipe': 'pipes', + 'record-pipes': 'pipes', 'list-ssr-requests': 'http', 'explain-ssr-request': 'http', 'list-http-calls': 'http', @@ -577,6 +578,7 @@ export const PAGE_AGENT_ENTRIES: readonly string[] = [ 'router-config', 'export-navigation', 'navigate', + 'record-pipes', 'analog-current-page', 'analog-server-calls', 'analog-call-api', diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index b2124a4a..131173d5 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -6,7 +6,7 @@ import { getComponents } from './rpc/get-components.ts'; import { getPipes } from './rpc/get-pipes.ts'; import { inspectProvidersText } from './rpc/injector-tools.ts'; import { lintPipes, lintPipesText } from './rpc/pipe-lint.ts'; -import { explainPipeText } from './rpc/pipe-explain.ts'; +import { explainPipeOnPage, pipeRecordRequest, pipeRecordText } from './rpc/pipes-agent.ts'; import { trackPageSessions } from './rpc/page-sessions.ts'; import { getBuildMeta } from './rpc/build-meta.ts'; import { getSignals } from './rpc/get-signals.ts'; @@ -440,18 +440,21 @@ const pangular = defineDevframe({ }, }); + // Shared by the panel's Record calls button and the record-pipes agent tool. + const requestPipeRecording = (message: unknown) => { + const request = pipeInstrumentRequest(message, pipePages); + // No page has reported yet: starting now would patch every connected tab. + if (request.on && !request.pageId) return { request, pages: { pages: 0 } }; + void my.rpc.broadcast({ method: 'instrument-pipes', args: [request], optional: true }); + if (!request.pageId) return { request, pages: { pages: pipePages.size } }; + return { request, pages: { pages: pipePages.has(request.pageId) ? 1 : 0 } }; + }; + register({ name: 'request-instrument-pipes', type: 'action', jsonSerializable: true, - handler: (message: unknown) => { - const request = pipeInstrumentRequest(message, pipePages); - // No page has reported yet: starting now would patch every connected tab. - if (request.on && !request.pageId) return { pages: 0 }; - void my.rpc.broadcast({ method: 'instrument-pipes', args: [request], optional: true }); - if (!request.pageId) return { pages: pipePages.size }; - return { pages: pipePages.has(request.pageId) ? 1 : 0 }; - }, + handler: (message: unknown) => requestPipeRecording(message).pages, }); register({ @@ -2496,19 +2499,34 @@ const pangular = defineDevframe({ type: 'object', properties: { name: { type: 'string', description: 'The pipe name as used after `|` in a template.' }, + page: PAGE_ARGUMENT, }, required: ['name'], }, - handler: async (args: { name?: string }) => { + handler: async (args: { name?: string; page?: string }) => { if (!args?.name) return { markdown: 'Pass a pipe `name`.' }; - return { - markdown: explainPipeText( - args.name, - ctx.cwd, - pipesState.value() as PipesState, - pipePages.size, - ), - }; + return { markdown: explainPipeOnPage(args.name, ctx.cwd, pipePages, pageArgument(args)) }; + }, + }); + + agent.registerTool({ + id: 'pangular:record-pipes', + description: + "Start or stop recording pipe calls on the running page, the same switch as Record calls in the Pipes panel. Recording wraps each pipe's transform() to count calls and keep the last input, output and caller, and turns on the experimental stale-argument check; pangular:explain-pipe then shows them. `start` records on the page `page` names, or on the most recent page; `stop` stops that page, or every page without `page`. Calls recorded so far stay after a stop.", + safety: 'read', + inputSchema: { + type: 'object', + properties: { + action: { type: 'string', enum: ['start', 'stop'] }, + page: PAGE_ARGUMENT, + }, + required: ['action'], + }, + handler: async (args: { action?: string; page?: string }) => { + const asked = pipeRecordRequest(args?.action, pipePages, pageArgument(args)); + if ('markdown' in asked) return asked; + const sent = requestPipeRecording(asked.request); + return { markdown: pipeRecordText(sent.request, sent.pages.pages) }; }, }); diff --git a/packages/devtools/src/rpc/__tests__/pipe-explain.test.ts b/packages/devtools/src/rpc/__tests__/pipe-explain.test.ts index bad2bc1e..5a87d2d1 100644 --- a/packages/devtools/src/rpc/__tests__/pipe-explain.test.ts +++ b/packages/devtools/src/rpc/__tests__/pipe-explain.test.ts @@ -67,7 +67,7 @@ describe('explainPipeText', () => { expect(text).not.toContain('Record calls'); }); - it('names the Record calls button when the pipe is in use and recording is off', () => { + it('names record-pipes and the Record calls button when the pipe is in use and recording is off', () => { const text = explainPipeText( 'appPrice', fixture(), @@ -77,7 +77,9 @@ describe('explainPipeText', () => { const live = liveLine(text); expect(live).toContain('1 instance(s), used by CartComponent (1).'); expect(live).toContain('No calls recorded, recording is off.'); - expect(live).toContain('Click **Record calls** in the Pipes panel'); + expect(live).toContain( + 'Start it with pangular:record-pipes (or **Record calls** in the Pipes panel)', + ); expect(live).not.toContain('Instrument'); expect(live).not.toContain('—'); }); diff --git a/packages/devtools/src/rpc/pipe-explain.ts b/packages/devtools/src/rpc/pipe-explain.ts index 0477874c..ca4e5aa2 100644 --- a/packages/devtools/src/rpc/pipe-explain.ts +++ b/packages/devtools/src/rpc/pipe-explain.ts @@ -85,7 +85,7 @@ const NOT_IN_USE = '**Live:** not in use on the connected page(s). No rendered component uses this pipe right now. Pipe discovery is always on, so this does not depend on recording.'; const RECORD_HINT = - 'No calls recorded, recording is off. Click **Record calls** in the Pipes panel for call counts, last input/output and stale-argument detection.'; + 'No calls recorded, recording is off. Start it with pangular:record-pipes (or **Record calls** in the Pipes panel) for call counts, last input/output and stale-argument detection.'; function runtimeText( runtime: PipeUsageInfo | undefined, diff --git a/packages/devtools/src/rpc/pipes-agent.ts b/packages/devtools/src/rpc/pipes-agent.ts new file mode 100644 index 00000000..206834c4 --- /dev/null +++ b/packages/devtools/src/rpc/pipes-agent.ts @@ -0,0 +1,77 @@ +import { code } from './forms-tools.ts'; +import { byRecency, unknownPageText } from './pages.ts'; +import { explainPipeText } from './pipe-explain.ts'; +import { currentPipes, type PipePageReport, type PipesState } from './pipes-tools.ts'; + +type PipePages = Map; + +const WHAT = 'pipe usage'; + +/** + * The live pipe data of one page: the one `page` names, or the page that + * reported most recently. Returns the answer for an unknown `page` instead. + */ +export function pipePageFor( + pages: PipePages, + page: string | undefined, +): { pageId?: string; state: PipesState } | { markdown: string } { + if (page && !pages.has(page)) { + return { markdown: unknownPageText(page, [...pages.values()], WHAT) }; + } + const pageId = page ?? byRecency(pages.values())[0]?.pageId; + const one: PipePages = new Map(); + const report = pageId ? pages.get(pageId) : undefined; + if (pageId && report) one.set(pageId, report); + return { pageId: report ? pageId : undefined, state: currentPipes(one) }; +} + +/** `explain-pipe` for one page, so several open tabs never mix their counts. */ +export function explainPipeOnPage( + name: string, + cwd: string, + pages: PipePages, + page: string | undefined, +): string { + const picked = pipePageFor(pages, page); + if ('markdown' in picked) return picked.markdown; + const text = explainPipeText(name, cwd, picked.state, picked.pageId ? 1 : 0); + if (!picked.pageId || pages.size < 2) return text; + return `Live data from page ${code(picked.pageId)}; ${pages.size} pages report ${WHAT}, pass \`page\` to pick another.\n\n${text}`; +} + +export type PipeRecordAction = 'start' | 'stop'; + +/** + * What `record-pipes` sends through `request-instrument-pipes`, or the answer + * when there is nothing to send. Start needs a page; stop without `page` + * reaches every page. + */ +export function pipeRecordRequest( + action: unknown, + pages: PipePages, + page: string | undefined, +): { request: { pageId?: string; on: boolean } } | { markdown: string } { + if (action !== 'start' && action !== 'stop') { + return { markdown: 'Pass `action`: "start" or "stop".' }; + } + if (page && !pages.has(page)) { + return { markdown: unknownPageText(page, [...pages.values()], WHAT) }; + } + if (!pages.size) { + return { + markdown: + 'No page reports pipe usage, so there is nothing to record. Live data needs a page: connect through the MCP endpoint of the server that runs the app, with the app open in a browser.', + }; + } + const on = action === 'start'; + return { request: page ? { pageId: page, on } : { on } }; +} + +/** The answer once `request-instrument-pipes` has sent the request. */ +export function pipeRecordText(request: { pageId?: string; on: boolean }, reached: number): string { + const where = request.pageId ? `page ${code(request.pageId)}` : `${reached} page(s)`; + if (request.on) { + return `Recording pipe calls on ${where}. Use the app, then call pangular:explain-pipe with the pipe name${request.pageId ? ' and the same `page`' : ''} for call counts, the last input, output and caller, and stale-argument checks. Recording wraps each pipe's transform(), so stop it when you are done.`; + } + return `Recording stopped on ${where}. The calls recorded so far stay until recording starts again.`; +} From 7a65eef68472c2db540e8786a17a523abe8beba8 Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 11 Oct 2026 05:01:53 +0000 Subject: [PATCH 2/3] docs(pipes): say that record-pipes stop without page stops every tab --- apps/docs/src/content/inspectors/pipes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/src/content/inspectors/pipes.md b/apps/docs/src/content/inspectors/pipes.md index 6ff7463c..d81f34dd 100644 --- a/apps/docs/src/content/inspectors/pipes.md +++ b/apps/docs/src/content/inspectors/pipes.md @@ -131,7 +131,7 @@ Look for **resubscribing** rows in **Async subscriptions**, and for `async-on-ca | `pangular:explain-pipe` | partly | One pipe by `name`: where it is declared or used, purity, live counts, last input and output, the stale warning, resubscribing `\| async` usages and the lint findings. | | `pangular:record-pipes` | yes | Starts or stops call recording with `action`, like **Record calls**. | -`explain-pipe` and `record-pipes` take `page` to pick a tab, and default to the most recent one. To give `explain-pipe` call data, call `record-pipes` with `action: "start"` or click **Record calls** in the panel, then use the app. See [Tools](../agents/tools.md#pipes). +`explain-pipe` and `record-pipes` take `page` to pick a tab, and default to the most recent one, except that `record-pipes` with `action: "stop"` and no `page` stops recording on every tab. To give `explain-pipe` call data, call `record-pipes` with `action: "start"` or click **Record calls** in the panel, then use the app. See [Tools](../agents/tools.md#pipes). ## Limits and gotchas From 51cedb03e0c97ca6694161ed2c7dad1fc7ece1d1 Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 11 Oct 2026 05:04:42 +0000 Subject: [PATCH 3/3] docs(pipes): list the page argument of explain-pipe and record-pipes --- apps/docs/src/content/agents/tools.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index e473211c..30b0aafe 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -352,9 +352,10 @@ It finds impure pipes used inside `@for`, `| json` left in templates, pure pipes `explain-pipe` explains one pipe: where it is declared or used, whether it is pure, live instance and call counts, the last input and output, a stale-value warning, `| async` usages that resubscribe on every check or subscribe to the same source as another usage, and lint findings. Reads: source, plus the page for live counts. -| Argument | Required | Value | -| -------- | -------- | ----------------------------------------------- | -| `name` | yes | The pipe name as used after `\|` in a template. | +| Argument | Required | Value | +| -------- | -------- | -------------------------------------------------- | +| `name` | yes | The pipe name as used after `\|` in a template. | +| `page` | no | The tab to read. Defaults to the most recent page. | Live data comes from one tab: the one `page` names, or the most recent. When more than one tab reports pipes, the answer starts with the tab it read. Live counts, input and output appear while recording is on. Start it with [`record-pipes`](#record-pipe-calls) or with **Record calls** in the [Pipes inspector](../inspectors/pipes.md). @@ -371,9 +372,10 @@ The **Live** line tells these cases apart: `record-pipes` starts or stops call recording on a page, the same switch as **Record calls** in the panel. Reads: page. -| Argument | Required | Value | -| -------- | -------- | ------------------ | -| `action` | yes | `start` or `stop`. | +| Argument | Required | Value | +| -------- | -------- | ---------------------------------------------------------------------------------------------- | +| `action` | yes | `start` or `stop`. | +| `page` | no | The tab to record on. Defaults to the most recent page for `start`, and every page for `stop`. | `start` records on the tab `page` names, or on the most recent tab. Other tabs stay unpatched. `stop` stops that tab, or every tab without `page`. Each recording starts from zero, and the calls recorded so far stay after a stop. Call `record-pipes` with `action: "start"`, use the app, then call `explain-pipe`. Recording is instrumentation, like the `record` argument of `change-detection`, so `agent.readOnly` and `actions` leave the tool in place. `agent.tools.pipes: false` hides it.