Docs

Automations — ui.status(), a search example, and the Runs panel Copy affordance

Three small, related pieces of tranquil-automations work landed 2026-08-13. The ui.status() decision is recorded in ADR-0024; the runner it builds on is ADR-0022, and the API lives in the writing-automations guide. This note is the delivery log.

1. ui.status() — a terminal-style status line

Scripts were hand-rolling the same “log to the Runs panel and pop a toast” helper to narrate progress. That combo is now a first-class SDK method on the ui namespace (tranquil-automations/deno/mod.ts), right beside ui.notify:

/** Terminal-style status line: log to the run output AND surface a popup. */
async status(
  message: string,
  options: { level?: "info" | "success" | "warning" | "error" } = {},
) {
  const level = options.level ?? "info";
  if (level === "error" || level === "warning") console.error(message);
  else console.log(message);
  await ui.notify(message, { level });
},
  • console.log/console.error land in the Automation Runs panel (the run’s captured stdio); ui.notify surfaces the popup. One call, both channels.
  • It references ui.notify (not this), so it survives being destructured out of the namespace.
  • Recorded in ADR-0024 and documented in the guide’s ui table; no new host capability was needed (it composes notify).

2. Reference example: DuckDuckGo search → .url bookmarks

A worked automation that doubles as a template for larger, config-driven browser automations. It reads inputs from a .env-style input.txt (so it can carry many inputs), searches DuckDuckGo, scrapes the top results, writes them as .url bookmark files into a new folder, and writes a summary to output.md — narrating each stage with ui.status. It leans on Deno std throughout (@std/dotenv, @std/async, @std/path, @std/fs, @std/text), all via jsr: (the runner always allows jsr.io imports, so no permission header is needed).

input.txt (sibling of the script):

query=business automation
count=10

The script:

// Read inputs from input.txt (key=value, like .env), search DuckDuckGo, save the top results as
// .url bookmarks in a new folder, and write a summary to output.txt. ui.status narrates each step.
import { tabs, ui, files, context } from "tranquil/automation";
import { parse } from "jsr:@std/dotenv/parse";
import { delay } from "jsr:@std/async@1/delay";
import { join } from "jsr:@std/path@1";
import { ensureDirSync } from "jsr:@std/fs@1";
import { slugify } from "jsr:@std/text@1/unstable-slugify";

// 1. Read + parse input.txt (sibling). Missing file or query → error popup.
let inputs: Record<string, string>;
try {
  inputs = parse(files.read("input.txt"));
} catch {
  await ui.status('Missing "input.txt" next to the script — add e.g.  query=business automation', { level: "error" });
  Deno.exit(1);
}
const term = (inputs.query ?? "").trim();
if (!term) {
  await ui.status('input.txt needs a "query=" line, e.g.  query=business automation', { level: "error" });
  Deno.exit(1);
}
const count = Math.min(Math.max(Number(inputs.count) || 10, 1), 25);

// 2. Search.
await ui.status(`▶ Searching DuckDuckGo for "${term}"…`);
const tab = await tabs.active();
await tab.goto("https://duckduckgo.com/?q=" + encodeURIComponent(term), { waitUntil: "load" });
await tab.waitFor('a[data-testid="result-title-a"]', { timeout: 15000 });

// 3. Load more, then scrape the top `count` results (title + link).
await ui.status(`Collecting top ${count} results…`);
await tab.evaluate(() => {
  const more = document.querySelector("#more-results") as HTMLButtonElement | null;
  more?.click();
});
await delay(1500);
const results = await tab.evaluate<{ title: string; url: string }[]>((n: number) => {
  const out: { title: string; url: string }[] = [];
  for (const a of document.querySelectorAll<HTMLAnchorElement>('a[data-testid="result-title-a"]')) {
    const u = new URL(a.href);
    if (u.hostname.endsWith("duckduckgo.com")) continue; // skip DDG ad/tracker links (y.js, etc.)
    out.push({ title: (a.textContent || "").trim(), url: u.origin + u.pathname }); // strip ?query#hash
    if (out.length >= n) break;
  }
  return out;
}, count);

// 4. Save each result as a .url bookmark in a new folder next to this script.
await ui.status(`Saving ${results.length} bookmarks…`);
const folder = slugify(term);
const dir = join(context.scriptDir, folder);
ensureDirSync(dir);
results.forEach((r, i) => {
  const name = `${String(i + 1).padStart(2, "0")}-${slugify(r.title) || "result"}.url`;
  Deno.writeTextFileSync(join(dir, name), `[InternetShortcut]\nURL=${r.url}\n`);
});

// 5. Write a Markdown summary to output.md, reveal it, and signal done.
const summary = [
  `# Search results: ${term}`,
  "",
  `_${results.length} result${results.length === 1 ? "" : "s"} · saved to `${folder}/`_`,
  "",
  ...results.map((r, i) => `${i + 1}. [${r.title}](${r.url})`),
  "",
].join("\n");
await ui.open(files.write("output.md", summary));
await ui.status(`■ Done — ${results.length} results → ${folder}/`, { level: "success" });

Patterns worth stealing:

  • Config in, results out, as sibling text files. There is no automation API for “the active editor,” so input.txt/output.md are fixed siblings resolved against context.scriptDir; files.read reads disk, so input.txt must be saved before a run. input.txt is never written.
  • .env inputs via @std/dotenv’s parse() — extensible to many keys (query, count, …); count is threaded into the page function as an argument (tab.evaluate((n) => …, count)).
  • .url bookmarks are literal [InternetShortcut]\nURL=<link>\n, matching what tranquil-browser reads back (/^URL=(.+)$/im). Filenames come from @std/text’s slugify.
  • Revealed output via the ui.open(files.write(...)) idiom — writes output.md and reloads it in place in the editor.
  • The scrape is CSP-safe: tab.evaluate(fn) runs via CDP Runtime.callFunctionOn, which the debugger compiles, so the function isn’t blocked by DuckDuckGo’s page CSP.

The DuckDuckGo SPA selectors (a[data-testid="result-title-a"], #more-results) are the parts most likely to need adjustment if the site changes; @std/text slugify (unstable-slugify) and @std/dotenv (pre-1.0) are the imports most likely to need a version/entrypoint tweak.

3. Automation Runs panel — Copy button + cmd+c selection

Two output-copy affordances were added to the Runs panel (tranquil-automations/lib/runs-panel.js, styles/runs-panel.less):

  • A Copy button in the selected run’s detail header, copying the run’s full output via atom.clipboard.write, with brief “Copied” feedback.
  • cmd+c / ctrl+c now copies a manual text selection in the output. Atom’s body keymap binds cmd-ccore:copy, which has no handler outside atom-text-editor, so on a plain <pre> the keystroke was swallowed. Fix: make the output <pre> focusable (tabindex = -1) and add a keydown handler that copies window.getSelection() and stopPropagations — the same “handle it here, not via the keymap” approach the browser find bar uses. Atom’s keymap listens on document in the bubble phase, so the element-level handler wins.
  • Both the Copy and Cancel buttons fire on mousedown, not click: the panel re-renders on every RunManager did-update (per output chunk) and on the 1s tick, rebuilding the buttons; a click needs mousedown+mouseup on the same element instance, so a re-render between press and release ate it. mousedown fires on press, matching the run-list rows.

Verification

  • deno check deno/mod.ts (against the seeded DOM-lib config) passes with ui.status added.
  • Runs panel: manually confirmed by the user — cmd+c copies selected output; the Copy button was fixed by the mousedown change (it silently no-op’d as a click during streaming runs).
  • The search example is a copy-to-run reference; run it with a saved input.txt and a browser tab open, then watch the status popups, the new bookmark folder, and output.txt.

Follow-ups

  • If ui.status proves popular, consider a quieter variant that only logs (no toast) for chatty loops.
  • The search example could graduate into tranquil-examples/Automations/browser/ as a shipped sample (it is currently a copy-paste reference, not committed).