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.errorland in the Automation Runs panel (the run’s captured stdio);ui.notifysurfaces the popup. One call, both channels.- It references
ui.notify(notthis), so it survives being destructured out of the namespace. - Recorded in ADR-0024 and documented in
the guide’s
uitable; no new host capability was needed (it composesnotify).
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.mdare fixed siblings resolved againstcontext.scriptDir;files.readreads disk, soinput.txtmust be saved before a run.input.txtis never written. .envinputs via@std/dotenv’sparse()— extensible to many keys (query,count, …);countis threaded into the page function as an argument (tab.evaluate((n) => …, count))..urlbookmarks are literal[InternetShortcut]\nURL=<link>\n, matching whattranquil-browserreads back (/^URL=(.+)$/im). Filenames come from@std/text’sslugify.- Revealed output via the
ui.open(files.write(...))idiom — writesoutput.mdand reloads it in place in the editor. - The scrape is CSP-safe:
tab.evaluate(fn)runs via CDPRuntime.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+cnow copies a manual text selection in the output. Atom’sbodykeymap bindscmd-c→core:copy, which has no handler outsideatom-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 copieswindow.getSelection()andstopPropagations — the same “handle it here, not via the keymap” approach the browser find bar uses. Atom’s keymap listens ondocumentin the bubble phase, so the element-level handler wins.- Both the Copy and Cancel buttons fire on
mousedown, notclick: the panel re-renders on every RunManagerdid-update(per output chunk) and on the 1s tick, rebuilding the buttons; aclickneeds mousedown+mouseup on the same element instance, so a re-render between press and release ate it.mousedownfires on press, matching the run-list rows.
Verification
deno check deno/mod.ts(against the seeded DOM-lib config) passes withui.statusadded.- Runs panel: manually confirmed by the user —
cmd+ccopies selected output; the Copy button was fixed by themousedownchange (it silently no-op’d as aclickduring streaming runs). - The search example is a copy-to-run reference; run it with a saved
input.txtand a browser tab open, then watch the status popups, the new bookmark folder, andoutput.txt.
Follow-ups
- If
ui.statusproves 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).