@ulpi/browse 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "@ulpi/browse",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ulpi-io/browse"
7
7
  },
8
8
  "dependencies": {
9
+ "@lightpanda/browser": "^1.2.0",
9
10
  "@ulpi/browse": "^0.3.0",
10
11
  "diff": "^7.0.0",
11
- "playwright": "^1.58.2"
12
+ "playwright": "^1.58.2",
13
+ "playwright-core": "^1.58.2"
12
14
  },
13
15
  "bin": {
14
16
  "browse": "bin/browse.ts"
@@ -52,5 +54,8 @@
52
54
  "devDependencies": {
53
55
  "@types/node": "^25.5.0",
54
56
  "typescript": "^5.9.3"
57
+ },
58
+ "optionalDependencies": {
59
+ "rebrowser-playwright": "^1.52.0"
55
60
  }
56
61
  }
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: browse
3
- version: 2.3.0
3
+ version: 2.5.0
4
4
  description: |
5
5
  Fast web browsing for AI coding agents via persistent headless Chromium daemon. Navigate to any URL,
6
6
  read page content, click elements, fill forms, run JavaScript, take screenshots,
@@ -89,6 +89,8 @@ If the file is missing or does not contain browse permission rules in `permissio
89
89
  - Always call `browse` as a bare command (it's on PATH via global install).
90
90
  - Do NOT use shell variables like `B=...` or full paths — they break Claude Code's permission matching.
91
91
  - NEVER use `#` in CSS selectors — use `[id=foo]` instead of `#foo`. The `#` character breaks Claude Code's permission matching and triggers approval prompts.
92
+ - After `goto`, always run `browse wait --network-idle` before reading content or taking screenshots. Pages with dynamic content, SPAs, and lazy-loaded assets need time to fully render.
93
+ - Screenshots MUST be saved to `.browse/sessions/default/` (or `.browse/sessions/<session-id>/` when using `--session`). Use descriptive filenames like `browse screenshot .browse/sessions/default/homepage.png`. NEVER save screenshots to `/tmp` or any other location.
92
94
  - The browser persists between calls — cookies, tabs, and state carry over.
93
95
  - The server auto-starts on first command. No manual setup needed.
94
96
  - Use `--session <id>` for parallel agent isolation. Each session gets its own tabs, refs, cookies.
@@ -105,8 +107,8 @@ browse goto https://example.com
105
107
  # Read cleaned page text
106
108
  browse text
107
109
 
108
- # Take a screenshot (then Read the image)
109
- browse screenshot .browse/sessions/default/screenshot.png
110
+ # Take a screenshot (then Read the image — saved to .browse/sessions/default/screenshot.png)
111
+ browse screenshot
110
112
 
111
113
  # Snapshot: accessibility tree with refs
112
114
  browse snapshot -i
@@ -226,6 +228,10 @@ browse screenshot-diff baseline.png current.png
226
228
  # Headed mode (visible browser)
227
229
  browse --headed goto https://example.com
228
230
 
231
+ # Stealth mode (bypasses bot detection)
232
+ # Requires: bun add rebrowser-playwright && npx rebrowser-playwright install chromium
233
+ browse --runtime rebrowser goto https://example.com
234
+
229
235
  # State list / show
230
236
  browse state list
231
237
  browse state show mysite
@@ -254,7 +260,8 @@ browse accessibility Accessibility tree snapshot (ARIA)
254
260
  ### Snapshot (ref-based element selection)
255
261
  ```
256
262
  browse snapshot Full accessibility tree with @refs
257
- browse snapshot -i Interactive elements only (buttons, links, inputs)
263
+ browse snapshot -i Interactive elements only — terse flat list (minimal tokens)
264
+ browse snapshot -i -v Interactive elements — verbose indented tree with props
258
265
  browse snapshot -c Compact (no empty structural elements)
259
266
  browse snapshot -C Cursor-interactive (detect divs with cursor:pointer/onclick/tabindex)
260
267
  browse snapshot -d <N> Limit depth to N levels
@@ -277,6 +284,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
277
284
  ### Interaction
278
285
  ```
279
286
  browse click <selector> Click element (CSS selector or @ref)
287
+ browse click <x>,<y> Click at page coordinates (e.g. 590,461)
280
288
  browse dblclick <selector> Double-click element
281
289
  browse fill <selector> <value> Fill input field
282
290
  browse select <selector> <val> Select dropdown value
@@ -426,6 +434,7 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
426
434
  | `--content-boundaries` | Wrap page content in nonce-delimited markers (prompt injection defense) |
427
435
  | `--allowed-domains <d,d>` | Block navigation/resources outside allowlist |
428
436
  | `--headed` | Run browser in headed (visible) mode |
437
+ | `--runtime <name>` | Browser engine: playwright (default), rebrowser (stealth) |
429
438
 
430
439
  ## Speed Rules
431
440
 
@@ -450,7 +459,7 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
450
459
  | Check if element exists | `count ".thing"` |
451
460
  | Get input value | `value "[id=email]"` |
452
461
  | Extract specific data | `js "document.querySelector('.price').textContent"` |
453
- | Visual check | `screenshot .browse/sessions/default/x.png` then Read the image |
462
+ | Visual check | `screenshot` then `Read .browse/sessions/default/screenshot.png` |
454
463
  | Fill and submit form | `snapshot -i` → `fill @e4 "val"` → `click @e5` |
455
464
  | Check/uncheck boxes | `check @e7` / `uncheck @e7` |
456
465
  | Check CSS | `css "selector" "property"` or `css @e3 "property"` |
@@ -479,6 +488,7 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
479
488
  | Visual regression | `screenshot-diff baseline.png` |
480
489
  | Debug with DevTools | `inspect` (set BROWSE_DEBUG_PORT first) |
481
490
  | See the browser | `browse --headed goto <url>` |
491
+ | Bypass bot detection | `--runtime rebrowser goto <url>` |
482
492
 
483
493
  ## Architecture
484
494
 
@@ -497,3 +507,4 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
497
507
  - AI-friendly error messages: Playwright errors rewritten to actionable hints
498
508
  - CDP remote connection: `BROWSE_CDP_URL` to connect to existing Chrome
499
509
  - Policy enforcement: `browse-policy.json` for allow/deny/confirm rules
510
+ - Two browser engines: playwright (default) and rebrowser (stealth, bypasses bot detection)
@@ -7,7 +7,8 @@
7
7
  * We do NOT try to self-heal — don't hide failure.
8
8
  */
9
9
 
10
- import { chromium, devices as playwrightDevices, type Browser, type BrowserContext, type Page, type Locator, type Frame, type FrameLocator, type Request as PlaywrightRequest } from 'playwright';
10
+ import { devices as playwrightDevices, type Browser, type BrowserContext, type Page, type Locator, type Frame, type FrameLocator, type Request as PlaywrightRequest } from 'playwright';
11
+ import { getRuntime } from './runtime';
11
12
  import { SessionBuffers, type LogEntry, type NetworkEntry } from './buffers';
12
13
  import type { HarRecording } from './har';
13
14
  import type { DomainFilter } from './domain-filter';
@@ -202,8 +203,9 @@ export class BrowserManager {
202
203
  * Launch a new Chromium browser (single-session / multi-process mode).
203
204
  * This instance owns the browser and will close it on close().
204
205
  */
205
- async launch(onCrash?: () => void) {
206
- this.browser = await chromium.launch({ headless: true });
206
+ async launch(onCrash?: () => void, runtimeName?: string) {
207
+ const runtime = await getRuntime(runtimeName);
208
+ this.browser = await runtime.chromium.launch({ headless: true });
207
209
  this.ownsBrowser = true;
208
210
 
209
211
  // Chromium crash → notify caller (server uses this to exit; tests ignore it)
package/src/bun.d.ts CHANGED
@@ -14,9 +14,23 @@ declare module 'bun' {
14
14
 
15
15
  export function spawn(cmd: string[], options?: {
16
16
  stdio?: Array<'ignore' | 'pipe' | 'inherit'>;
17
+ stdout?: 'pipe' | 'ignore' | 'inherit';
18
+ stderr?: 'pipe' | 'ignore' | 'inherit';
19
+ stdin?: 'pipe' | 'ignore' | 'inherit';
17
20
  env?: Record<string, string | undefined>;
18
21
  }): BunSubprocess;
19
22
 
23
+ export function listen(options: {
24
+ hostname: string;
25
+ port: number;
26
+ socket: {
27
+ data: (...args: any[]) => void;
28
+ open?: (...args: any[]) => void;
29
+ close?: (...args: any[]) => void;
30
+ error?: (...args: any[]) => void;
31
+ };
32
+ }): BunTCPServer;
33
+
20
34
  export function sleep(ms: number): Promise<void>;
21
35
 
22
36
  export const stdin: { text(): Promise<string> };
@@ -26,10 +40,18 @@ declare module 'bun' {
26
40
  stop(): void;
27
41
  }
28
42
 
43
+ interface BunTCPServer {
44
+ port: number;
45
+ stop(): void;
46
+ }
47
+
29
48
  interface BunSubprocess {
30
49
  pid: number;
50
+ exitCode: number | null;
31
51
  stderr: ReadableStream<Uint8Array> | null;
32
52
  stdout: ReadableStream<Uint8Array> | null;
53
+ exited: Promise<number>;
54
+ kill(signal?: number): void;
33
55
  unref(): void;
34
56
  }
35
57
  }
@@ -37,6 +59,7 @@ declare module 'bun' {
37
59
  declare var Bun: {
38
60
  serve: typeof import('bun').serve;
39
61
  spawn: typeof import('bun').spawn;
62
+ listen: typeof import('bun').listen;
40
63
  sleep: typeof import('bun').sleep;
41
64
  stdin: typeof import('bun').stdin;
42
65
  };
package/src/cli.ts CHANGED
@@ -605,8 +605,8 @@ Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
605
605
  element-state <sel> | console [--clear] | network [--clear]
606
606
  cookies | storage [set <k> <v>] | perf
607
607
  value <sel> | count <sel> | clipboard [write <text>]
608
- Visual: screenshot [path] [--full] [--annotate] | pdf [path] | responsive [prefix]
609
- Snapshot: snapshot [-i] [-c] [-C] [-d N] [-s sel]
608
+ Visual: screenshot [path] | pdf [path] | responsive [prefix]
609
+ Snapshot: snapshot [-i] [-v] [-c] [-C] [-d N] [-s sel]
610
610
  Find: find role|text|label|placeholder|testid <query> [name]
611
611
  Compare: diff <url1> <url2> | screenshot-diff <baseline> [current]
612
612
  Multi-step: chain (reads JSON from stdin)
@@ -632,7 +632,8 @@ Options:
632
632
  --headed Run browser in headed (visible) mode
633
633
 
634
634
  Snapshot flags:
635
- -i Interactive elements only (buttons, links, inputs)
635
+ -i Interactive elements only (terse flat list by default)
636
+ -v Verbose — full indented tree with props (use with -i)
636
637
  -c Compact — remove empty structural elements
637
638
  -C Cursor-interactive — detect divs with cursor:pointer,
638
639
  onclick, tabindex, data-action (missed by ARIA tree)
@@ -73,7 +73,16 @@ export async function handleWriteCommand(
73
73
 
74
74
  case 'click': {
75
75
  const selector = args[0];
76
- if (!selector) throw new Error('Usage: browse click <selector>');
76
+ if (!selector) throw new Error('Usage: browse click <selector|x,y>');
77
+ // Coordinate click: "590,461" or "590, 461"
78
+ const coordMatch = selector.match(/^(\d+)\s*,\s*(\d+)$/);
79
+ if (coordMatch) {
80
+ const x = parseInt(coordMatch[1], 10);
81
+ const y = parseInt(coordMatch[2], 10);
82
+ await page.mouse.click(x, y);
83
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
84
+ return `Clicked at (${x}, ${y}) → now at ${page.url()}`;
85
+ }
77
86
  const resolved = bm.resolveRef(selector);
78
87
  if ('locator' in resolved) {
79
88
  await resolved.locator.click({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
package/src/config.ts CHANGED
@@ -14,6 +14,7 @@ export interface BrowseConfig {
14
14
  idleTimeout?: number;
15
15
  viewport?: string;
16
16
  device?: string;
17
+ runtime?: string;
17
18
  }
18
19
 
19
20
  /**
@@ -0,0 +1,7 @@
1
+ /**
2
+ * rebrowser-playwright is a drop-in fork of playwright with stealth patches.
3
+ * Re-export playwright's types so tsc passes without the package installed.
4
+ */
5
+ declare module 'rebrowser-playwright' {
6
+ export * from 'playwright';
7
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Browser runtime provider registry
3
+ *
4
+ * Abstracts the browser engine so playwright, rebrowser, etc. are swappable at runtime.
5
+ * Each runtime is loaded lazily via dynamic import -- only the selected runtime is loaded.
6
+ *
7
+ * Two kinds of runtime:
8
+ * Library runtimes (playwright, rebrowser) -- return a BrowserType for launch()/connectOverCDP()
9
+ * Process runtimes (lightpanda) -- spawn a binary, wait for CDP, connect Playwright to it
10
+ * These set `browser` (pre-connected) and `close` (cleanup spawned process).
11
+ */
12
+
13
+ import { homedir } from 'os';
14
+ import { existsSync } from 'fs';
15
+ import { execSync } from 'child_process';
16
+ import { join } from 'path';
17
+ import type { Browser, BrowserType } from 'playwright';
18
+
19
+ export interface BrowserRuntime {
20
+ name: string;
21
+ chromium: BrowserType;
22
+ /** Pre-connected browser (for process runtimes like lightpanda). */
23
+ browser?: Browser;
24
+ /** Cleanup function -- kills spawned process, if any. */
25
+ close?: () => Promise<void>;
26
+ }
27
+
28
+ type RuntimeLoader = () => Promise<BrowserRuntime>;
29
+
30
+ /**
31
+ * Find the lightpanda binary on this machine.
32
+ * Checks PATH via `which`, then well-known install locations.
33
+ * Returns the absolute path or null if not found.
34
+ */
35
+ export function findLightpanda(): string | null {
36
+ // Check PATH
37
+ try {
38
+ const result = execSync('which lightpanda', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
39
+ if (result) return result;
40
+ } catch {
41
+ // not on PATH
42
+ }
43
+
44
+ // Check well-known install locations
45
+ const home = homedir();
46
+ const candidates = [
47
+ join(home, '.lightpanda', 'lightpanda'),
48
+ join(home, '.local', 'bin', 'lightpanda'),
49
+ ];
50
+ for (const candidate of candidates) {
51
+ if (existsSync(candidate)) return candidate;
52
+ }
53
+
54
+ return null;
55
+ }
56
+
57
+ const registry: Record<string, RuntimeLoader> = {
58
+ playwright: async () => {
59
+ const pw = await import('playwright');
60
+ return { name: 'playwright', chromium: pw.chromium };
61
+ },
62
+
63
+ rebrowser: async () => {
64
+ try {
65
+ const pw = await import('rebrowser-playwright');
66
+ return { name: 'rebrowser', chromium: pw.chromium };
67
+ } catch {
68
+ throw new Error(
69
+ 'rebrowser-playwright not installed. Run: bun add rebrowser-playwright'
70
+ );
71
+ }
72
+ },
73
+
74
+ lightpanda: async () => {
75
+ const binaryPath = findLightpanda();
76
+ if (!binaryPath) {
77
+ throw new Error(
78
+ 'Lightpanda not found. Install: https://lightpanda.io/docs/open-source/installation'
79
+ );
80
+ }
81
+
82
+ // Find a free port
83
+ const server = Bun.listen({
84
+ hostname: '127.0.0.1',
85
+ port: 0,
86
+ socket: {
87
+ data() {},
88
+ open() {},
89
+ close() {},
90
+ error() {},
91
+ },
92
+ });
93
+ const port = server.port;
94
+ server.stop();
95
+
96
+ // Spawn lightpanda with 1-week session timeout (documented maximum)
97
+ const child = Bun.spawn(
98
+ [binaryPath, 'serve', '--host', '127.0.0.1', '--port', String(port), '--timeout', '604800'],
99
+ { stdout: 'pipe', stderr: 'pipe' }
100
+ );
101
+
102
+ // Poll for CDP ready (10s timeout, 100ms interval)
103
+ const deadline = Date.now() + 10_000;
104
+ let wsUrl: string | undefined;
105
+
106
+ while (Date.now() < deadline) {
107
+ // Check if process exited early
108
+ if (child.exitCode !== null) {
109
+ const stderr = await new Response(child.stderr).text().catch(() => '');
110
+ throw new Error(
111
+ `Lightpanda exited before CDP became ready (exit code: ${child.exitCode})` +
112
+ (stderr ? `\nstderr: ${stderr.slice(0, 2000)}` : '')
113
+ );
114
+ }
115
+
116
+ try {
117
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`);
118
+ if (res.ok) {
119
+ const data = await res.json() as { webSocketDebuggerUrl?: string };
120
+ wsUrl = data.webSocketDebuggerUrl;
121
+ break;
122
+ }
123
+ } catch {
124
+ // Not ready yet
125
+ }
126
+ await new Promise(r => setTimeout(r, 100));
127
+ }
128
+
129
+ if (!wsUrl) {
130
+ child.kill();
131
+ throw new Error(
132
+ `Lightpanda failed to start on port ${port} within 10 seconds`
133
+ );
134
+ }
135
+
136
+ // Bun's WebSocket client sends "Connection: keep-alive" instead of
137
+ // "Connection: Upgrade", breaking the WebSocket handshake with Lightpanda.
138
+ // Playwright's connectOverCDP relies on the bundled ws package which Bun
139
+ // intercepts. This is a known Bun issue (oven-sh/bun#9911).
140
+ // Until Bun fixes WebSocket upgrades, lightpanda cannot be used.
141
+ child.kill();
142
+ throw new Error(
143
+ 'Lightpanda runtime is not yet supported under Bun.\n' +
144
+ 'Bun\'s WebSocket client breaks the HTTP upgrade handshake required by connectOverCDP (oven-sh/bun#9911).\n' +
145
+ 'This affects all CDP-based connections. Use --runtime playwright or --runtime rebrowser instead.'
146
+ );
147
+ },
148
+ };
149
+
150
+ export const AVAILABLE_RUNTIMES: string[] = Object.keys(registry);
151
+
152
+ export async function getRuntime(name?: string): Promise<BrowserRuntime> {
153
+ const key = name ?? 'playwright';
154
+ const loader = registry[key];
155
+ if (!loader) {
156
+ throw new Error(
157
+ `Unknown runtime: ${key}. Available: ${AVAILABLE_RUNTIMES.join(', ')}`
158
+ );
159
+ }
160
+ return loader();
161
+ }
package/src/server.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * Auto-shutdown when all sessions idle past BROWSE_IDLE_TIMEOUT (default 30 min)
10
10
  */
11
11
 
12
- import { chromium, type Browser } from 'playwright';
12
+ import type { Browser } from 'playwright';
13
+ import { getRuntime, type BrowserRuntime } from './runtime';
13
14
  import { SessionManager, type Session } from './session-manager';
14
15
  import { handleReadCommand } from './commands/read';
15
16
  import { handleWriteCommand } from './commands/write';
@@ -99,6 +100,7 @@ function flushSessionBuffers(session: Session, final: boolean) {
99
100
  // ─── Server ────────────────────────────────────────────────────
100
101
  let sessionManager: SessionManager;
101
102
  let browser: Browser;
103
+ let activeRuntime: BrowserRuntime | undefined;
102
104
  let isShuttingDown = false;
103
105
  let isRemoteBrowser = false;
104
106
  const policyChecker = new PolicyChecker();
@@ -326,6 +328,9 @@ async function shutdown() {
326
328
  await browser.close().catch(() => {});
327
329
  }
328
330
 
331
+ // Clean up runtime resources (e.g. lightpanda child process)
332
+ await activeRuntime?.close?.().catch(() => {});
333
+
329
334
  // Only remove state file if it still belongs to this server instance.
330
335
  try {
331
336
  const currentState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
@@ -365,13 +370,27 @@ const sessionCleanupInterval = setInterval(async () => {
365
370
  async function start() {
366
371
  const port = await findPort();
367
372
 
373
+ // Resolve browser runtime (playwright, rebrowser, etc.)
374
+ const runtimeName = process.env.BROWSE_RUNTIME;
375
+ const runtime = await getRuntime(runtimeName);
376
+ activeRuntime = runtime;
377
+ console.log(`[browse] Runtime: ${runtime.name}`);
378
+
368
379
  // Launch or connect to browser
369
380
  const cdpUrl = process.env.BROWSE_CDP_URL;
370
381
  if (cdpUrl) {
371
382
  // Connect to remote Chrome via CDP
372
- browser = await chromium.connectOverCDP(cdpUrl);
383
+ browser = await runtime.chromium.connectOverCDP(cdpUrl);
373
384
  isRemoteBrowser = true;
374
385
  console.log(`[browse] Connected to remote Chrome via CDP: ${cdpUrl}`);
386
+ } else if (runtime.browser) {
387
+ // Process runtime (e.g. lightpanda) -- browser already connected
388
+ browser = runtime.browser;
389
+ browser.on('disconnected', () => {
390
+ if (isShuttingDown) return;
391
+ console.error('[browse] Browser disconnected. Shutting down.');
392
+ shutdown();
393
+ });
375
394
  } else {
376
395
  // Launch local Chromium
377
396
  const launchOptions: Record<string, any> = { headless: process.env.BROWSE_HEADED !== '1' };
@@ -385,7 +404,7 @@ async function start() {
385
404
  launchOptions.proxy.bypass = process.env.BROWSE_PROXY_BYPASS;
386
405
  }
387
406
  }
388
- browser = await chromium.launch(launchOptions);
407
+ browser = await runtime.chromium.launch(launchOptions);
389
408
 
390
409
  // Chromium crash → clean shutdown (only for owned browser)
391
410
  browser.on('disconnected', () => {
package/src/snapshot.ts CHANGED
@@ -30,7 +30,8 @@ const INTERACTIVE_ROLES = new Set([
30
30
  ]);
31
31
 
32
32
  interface SnapshotOptions {
33
- interactive?: boolean; // -i: only interactive elements
33
+ interactive?: boolean; // -i: only interactive elements (terse flat list by default)
34
+ verbose?: boolean; // -v: full indented ARIA tree with props/children (overrides -i terse default)
34
35
  compact?: boolean; // -c: remove empty structural elements
35
36
  depth?: number; // -d N: limit tree depth
36
37
  selector?: string; // -s SEL: scope to CSS selector
@@ -72,6 +73,10 @@ export function parseSnapshotArgs(args: string[]): SnapshotOptions {
72
73
  case '--compact':
73
74
  opts.compact = true;
74
75
  break;
76
+ case '-v':
77
+ case '--verbose':
78
+ opts.verbose = true;
79
+ break;
75
80
  case '-C':
76
81
  case '--cursor':
77
82
  opts.cursor = true;
@@ -417,10 +422,18 @@ export async function handleSnapshot(
417
422
  refMap.set(ref, locator);
418
423
 
419
424
  // Format output line
420
- let outputLine = `${indent}@${ref} [${node.role}]`;
421
- if (node.name) outputLine += ` "${node.name}"`;
422
- if (node.props) outputLine += ` ${node.props}`;
423
- if (node.children) outputLine += `: ${node.children}`;
425
+ // -i without -v: terse flat list (no indent, no props, no children)
426
+ const terse = opts.interactive && !opts.verbose;
427
+ let outputLine: string;
428
+ if (terse) {
429
+ outputLine = `@${ref} [${node.role}]`;
430
+ if (node.name) outputLine += ` "${node.name}"`;
431
+ } else {
432
+ outputLine = `${indent}@${ref} [${node.role}]`;
433
+ if (node.name) outputLine += ` "${node.name}"`;
434
+ if (node.props) outputLine += ` ${node.props}`;
435
+ if (node.children) outputLine += `: ${node.children}`;
436
+ }
424
437
 
425
438
  output.push(outputLine);
426
439
  }