Chrome in your terminal!
- runs headless chrome, and streams it into kitty graphics windows in your terminal (including tmux).
--loginopens a real window withweb's profile, so you can use your logins- includes a tabs implementation that works in the terminal (with grid view, and a keybind to merge all tabs from all instances into the current window)
- supports text entry, mouseovers, etc.
- can be driven by JS, playwright, or MCP
- history and bookmark support, with fuzzy search
Some of my other projects that run in the terminal:
- rom - a terminal game emulator (gba, snes, etc.)
- pix - a terminal image viewer with grid layout
- vid - a terminal video player with subtitle support
- piano - a tiny piano, with playback and pratice mode
Requires Ghostty or kitty,
Google Chrome, and a C compiler. The only library is sqlite3, which macOS ships
and Linux packages as libsqlite3-dev.
brew install jhickner/tap/web
web news.ycombinator.comOr from source:
make
./web news.ycombinator.comEach address after the first opens in a tab; the window starts on the first:
./web news.ycombinator.com lobste.rs example.comAn argument that is not an address opens the bookmark whose name or address holds every word of it, and is a host or a search when no bookmark does:
./web hn
./web 'hacker news'In tmux, add to ~/.tmux.conf:
set -g allow-passthrough allOpens a normal browser window on the same profile. Log in to whatever sites you
need and web keeps the session:
web --loginEvery window shares one profile — ~/.cache/web/profile — unless --profile
names another, which lives in ~/.cache/web/profiles/<name>:
web --profile work --login # its own logins
web --profile work news.ycombinator.com
web --profile - --screenshot shot.png example.com # throwaway, removed on exitA named profile gets its own browser, history, and tabs. --endpoint, --list,
--kill and --open answer for one profile. --exec puts the profile in the
environment as WEB_PROFILE, which a window started from there inherits.
| Key | Action |
|---|---|
^L |
address bar; a phrase opens the bookmark it names, or becomes a search |
^O / ^P |
back / forward |
^R |
reload |
^Y |
copy the page selection, or the address if nothing is selected |
^E |
open this page in the desktop browser |
^F |
find in page, then f3 / shift+f3 |
^G |
hide or show the status line |
^D |
start or stop a trace into /tmp/web_input.log |
^Q |
quit |
^T / ^W |
new tab / close tab (the last one closes the window) |
^N / ^B |
next tab / previous tab (shift+alt+→ / shift+alt+← too) |
alt+m |
every other window's tabs into this one; those windows close |
alt+1…alt+9 |
go to that tab |
alt+0 |
reset zoom, pinned width, and window proportion |
alt+f |
fit-to-width on/off |
cmd+c |
copy the page selection |
cmd+v |
paste into the page, or into the address bar when it is open |
^X |
open or close the console |
alt+y |
copy the console transcript |
alt+enter |
let a frozen run carry on |
alt+g |
every tab at once, in a grid — see The grid |
alt+r |
start or stop writing what you do as a spec — see Recording |
alt+d |
bookmark this page, or take the bookmark off it |
alt+space |
play or pause the page's video, whatever has focus |
alt+b |
find a bookmark — see Finding a page |
? |
key list over the page, a page at a time; any other key dismisses it |
| mouse | click, drag to select, wheel to scroll, hover to lift what a pointer lifts |
opt+click |
open the link under the pointer in a tab behind this one (middle-click too) |
| tab bar | click to switch, middle-click to close, wheel to cycle |
| console border | drag up or down to resize the pane |
While reading. Letters this table does not name are typed into the page;
vim = yes gives most of them to the window instead — see Vim keys.
| Key | Action |
|---|---|
↓ / ↑ |
down / up a line, or to a focused player |
← / → |
passed to the page |
pgdn / pgup |
a screen (space too, and space goes to a focused player) |
backspace / shift+backspace |
back / forward |
[ / ] |
zoom out / in; inline, scales the window from both edges |
shift+↑↓←→ |
drag one window edge (U/D/L/R too) |
w / W |
widen / narrow the width the page is told it has |
s |
frame size: auto, 100%, 75%, 50% |
f / F |
label the links, then type a label: follow it / open it in a tab |
P |
picking: click the page for a CSS selector, into the console |
i |
hand the keyboard to the page |
: |
open the console |
? |
key list; ← / → or space to turn its pages, dots below say which |
esc |
take the keyboard back |
Set vim = yes in ~/.config/web/web.conf. Without it no letter moves the
page — j types a j — and the window is driven by the arrows and the chords.
With it:
| Key | Action |
|---|---|
j / k |
down / up |
h / l |
left / right, for a page wider than its given width |
d / u |
half a screen (^D / ^U too) |
b |
a screen back; space forward, and ^F / ^B for both |
gg / G |
top / bottom |
/ |
find in page, then n / N |
H / L |
back / forward |
o / O |
address bar: this tab, with the current address / a new tab, blank |
: |
address bar, blank |
r / R |
reload / reload ignoring the cache |
T |
find an open tab by name — see Finding a page |
gh |
find a page been to before |
m |
bookmark this page, or unbookmark it (alt+d too) |
gb |
find a bookmark |
gi |
focus the first text field |
gf |
label everything clickable, past any hint-only rule for the site |
yy |
copy the address |
yf |
label the links, then type a label to copy its address |
ZZ |
quit |
f and F label the links in either mode, and everything else in the reading
table still applies: [ ] w W s for the window, P, i, esc, ?.
Six keys the plain map uses change meaning while this is on: L and R stop
sizing the window (shift+←/→ still do), : stops opening the console
(^X still does), ^F stops finding (/ does), ^D stops tracing, and ^B
stops going to the previous tab (shift+alt+← still does).
A key web.conf names keeps what the file gave it, and the status line says how
many the file took back. The generated file writes the defaults as comments; a
file from an earlier version has them live, so comment the key block out to
follow the defaults again. A line that hides a pair — an old g = top over
gg — is reported on stderr.
f puts a label on everything clickable in view. Type a label to click it,
F to open it in a new tab, esc to put the labels away, backspace to undo
a character. Labels are a s d f g h j k l, short ones first
and from the top of the page down; a label that can only be one thing fires
without waiting. Every key goes to the labels while they are up.
Links, buttons, fields, and anything carrying role, onclick or tabindex
are labelled, in the page and in same-origin frames and open shadow roots.
Something covered by a banner or a modal is not. The click is a real mouse
click at the label's own position, so hover, focus and the popups a click is
allowed to open all behave as they would from the mouse.
Per-host CSS selectors in web.conf decide which elements get labels:
hint-only news.ycombinator.com = .titleline > a
hint-skip github.com = .Header, footer
hint-only replaces the default set for that host; add ,input,button to keep
fields and buttons. hint-skip keeps the default set and drops what the
selector matches, along with everything inside it. Both may be given for one
host.
The host matches the end of the page's own on a dot, so ycombinator.com
covers news.ycombinator.com; the first rule of each kind that matches wins.
hint-all labels everything whatever the rules say — gf under the vim keys,
bindable anywhere else.
pause-on-blur, media-pause-on-blur and hide-on-blur each take a host in
front of the = and apply to that host alone:
pause-on-blur youtube.com = no
media-pause-on-blur youtube.com = yes
media-pause-on-blur twitch.tv = no
hide-on-blur youtube.com = yes
A host named here wins over the file-wide setting of the same name; written without one, the line is the file-wide setting. Values are the booleans every other setting takes, and a line whose value is neither is reported and dropped. The host matches the way the hint rules' does, and the first rule of each kind that matches wins.
A host set to no for pause-on-blur keeps hide-on-blur off it as well, and
stops media-pause-on-blur for it too — a page still being drawn from another
pane is one still being watched. A media-pause-on-blur line for that host
says otherwise and wins, as the second line above does.
media-pause-on-blur no for a host is also what keeps it playing from a tab
that is not the one in front.
--no-pause turns all three off for the run whatever any of them say;
--no-media-pause turns off the playing alone.
Every folder under ~/.config/web/extensions holding a manifest.json is
loaded, and --extension D loads one more. A folder is wanted, not a .crx,
with manifest.json at its root:
curl -fsSLO https://github.com/uBlockOrigin/uBOL-home/releases/latest/download/uBOLite.chromium.zip
unzip uBOLite.chromium.zip -d ~/.config/web/extensions/uboliteextensions = no in web.conf leaves them all alone. Installing from the
Chrome Web Store through --login does not carry to this window.
alt+g draws every tab at once, up to nine, each in a tile with its name under
it:
| Key | Action |
|---|---|
alt+g |
open the grid, or close it |
↓ ↑ ← → |
pick the tile next to this one |
enter |
open the tile that is in front |
esc |
the same |
alt+1…alt+9 |
pick that tile |
| click a tile | pick it; clicking the one in front opens it |
A page opened by something driving the window — one per worker of a test run — becomes a tab as it appears and goes when it goes.
Each page is photographed at the size of its own tile, so it reflows to the width it is drawn at rather than being a shrunken screenshot of a whole window. One photograph at a time, a few a second, round the tiles in turn and twice as often on the one in front. A page that has not changed is not drawn again. The screencast stops while the grid is up.
Three lists, opened over the page and narrowed as you type: search-tabs for
the tabs already open, search-history for the pages this profile has been to,
search-bookmarks for the pages kept on purpose. T, gh and alt+b / gb,
bindable anywhere else.
| Key | Action |
|---|---|
| any letter | narrow the list |
↓ / ↑ |
down / up (^N / ^P and tab / shift+tab too) |
pgdn / pgup |
a screenful |
enter |
go there, or switch to the tab if it is one already open |
shift+enter / ^T |
open it in a new tab |
backspace / ^U |
delete a character / the line |
esc / ^G |
put the list away |
Every word typed has to appear in the title or the address, in any order, so
hacker news and news hacker find the same page and each word narrows the
list further. Case is ignored until you type a capital, and then it matters.
Ranking is how well the words match the title and address, plus recency out to a month old; visit count is not counted. With nothing typed the history opens on the most recent page first, and tabs are ranked on the words alone.
The history is Chrome's own, read from History in the profile
(~/.cache/web/profile) — everything opened in web, including from a
--login window.
The bookmarks are Chrome's own, read and written in Bookmarks in the profile.
alt+d (m under the vim keys, action bookmark) puts the page in front at
the end of the bookmark bar, or takes it out of wherever it sits when it is
already bookmarked; the address has to match character for character. A
bookmarked page has a ★ before its name on the status line. The list is every
folder, not only the bar, newest-added first.
A bookmark made in a --login window while web is up can overwrite one made
here in the same session. The next start reads what is on disk.
A line typed in the address bar that is not itself an address — a phrase, or a word with no dot in it — opens the bookmark whose name or address holds every word of it, the same rule an argument on the command line follows. With no bookmark to match it, the line is the host or the search it was before.
~/.config/web/web.conf, written with the defaults on first run:
| Setting | Default | Values | Description |
|---|---|---|---|
vim |
no |
yes/no | the vim key layer, under whatever keys this file names |
extensions |
yes |
yes/no | load the unpacked extensions in ~/.config/web/extensions — see Extensions |
pause-on-blur |
yes |
yes/no | stop drawing while the terminal is not focused. A window being driven by --exec or a script draws either way |
hide-on-blur |
no |
yes/no | take the window off the screen while the terminal is not focused, instead of leaving the last frame up. Pauses as well, whatever pause-on-blur says |
media-pause-on-blur |
yes |
yes/no | pause whatever is playing in a page while the terminal is not focused, and while its tab is not the one in front; start it again on the way back. Only what it stopped itself starts again |
hover |
yes |
yes/no | tell the page where the pointer is with no button down, so menus, tooltips and video controls come up under it |
status-line |
yes |
yes/no | show the status line under the page |
clear-on-exit |
yes |
yes/no | erase the window on exit instead of leaving it |
full |
no |
yes/no | take over the whole terminal instead of drawing a window |
mute |
no |
yes/no | start with the page's audio switched off |
raw-keys |
no |
yes/no | let a key the page did not want reach the window system |
keep |
no |
yes/no | leave Chrome running on exit |
scale |
auto |
auto, 0.1–3 |
frame size as a fraction of the viewport; auto is full size when the page is still, smaller while it moves |
motion-scale |
0.65 |
0.1–1 | frame width while the page is moving, as a fraction of its still size; 1 keeps it full size throughout. Over ssh the default is 0.5 |
still-delay |
300 |
50–5000 | milliseconds with no frame and no key before the page counts as still and the full-size picture goes back |
zoom |
1.5 |
0.5–3 | page magnification a new window opens at |
rows |
40 |
auto, a count |
cell rows a new window opens at |
cols |
80 |
auto, a count |
cell columns a new window opens at |
slowmo |
0 |
0–60000 | milliseconds between the actions of what --exec starts |
freeze |
no |
yes/no | hold the page where a driver failed until alt+enter |
grid |
no |
yes/no | show the grid whenever there is more than one page |
tmux-zoom |
no |
yes/no | grow the window to fill the pane while tmux has it zoomed, and put its size back when the zoom ends |
graphics-check |
yes |
yes/no | ask the terminal whether it draws kitty graphics at startup, and exit with a message when it says no |
search |
https://www.google.com/search?q=%s |
an http url with %s |
where a phrase goes when no bookmark matches it |
Booleans also take true, on and 1. Each setting is the command line
option of the same name, which wins for that run; motion-scale and
still-delay are this file only. hint-only, hint-skip and a pause-on-blur,
media-pause-on-blur or hide-on-blur with a host in front of its = go in
the same file, one per site — see Site rules.
motion-scale and still-delay apply under scale = auto and nowhere else. A
still-delay below about 100 ends a scroll in the middle of itself.
search takes the words in place of its %s, percent-encoded:
search = https://duckduckgo.com/?q=%s
search = https://lite.duckduckgo.com/lite/?q=%s
search = https://www.bing.com/search?q=%s~/.config/web/start.html is written beside it on the same run, and that run
opens on it — in a tab behind the page when the command line named an address.
It is never written over, so it can be edited or emptied.
zoom, rows and cols are where a new window opens. [ ] alt+0 and
shift+arrows move a running window from there, and nothing is written back.
rows and cols do nothing under full = yes; a cols wider than the
terminal is drawn at the terminal's width.
Keys go in the same file:
shift-alt-right = tab-next
^y = copy
y = copy-url
f5 = reload
gg = top
g i = focus-input
Keys are ctrl alt shift cmd joined by + or -, then a character or
one of left right up down space esc enter tab backspace
delete home end pgup pgdn f1–f12. ^y is ctrl+y. Action names
are the ones in the generated file; none unbinds; deleting a line restores
its default. A key without ctrl, alt or cmd acts only while reading.
Two keys pressed one after the other are a binding too, written as a pair of
characters (gg, yf) or with a space between them where either needs a name
or a modifier (g i, ^X f). The first key of a pair shows on the status line
while it waits. A key bound on its own happens at once, so the pairs starting
with it are never reached: bind y = none before binding yy.
web [options] <url>...
--scale F hold the frame at F of the viewport (default auto: full size
when the page is still, smaller while it moves). Above 1 does
nothing; the screencast never exceeds the viewport
--zoom F page magnification (default 1.5)
--rows N how many cell rows the window gets (default 40)
--cols N how many cell columns the window gets (default 80, and
never wider than the terminal)
--extension D load the unpacked extension in folder D
--no-status start with the status line hidden (^G toggles it)
--no-clear leave the window on screen on exit instead of erasing it
--full take over the whole terminal instead of drawing a window
--show run Chrome with a visible window too
--mute start with the page's audio switched off
--eval JS run javascript in the page and print what it answers
--delay MS pause between lines of piped javascript
--step wait for a key between those lines
--timeout S how long a line waits before giving up (default 5)
--json script output as one JSON object per value
--screenshot F write the page to F as a png and exit; "-" is stdout
--login open a window to sign in with, on the same profile
--keep leave Chrome running on exit, for this window and every other
--open URL open URL in a tab of the window most recently used, and exit.
Nothing running is an error
--endpoint print every running window as JSON and exit
--mcp drive the window on screen as an MCP server on stdio, for an
agent to work the page you are watching
--list list the Chrome processes web has running, with pids, and say
which a new window could adopt
--kill quit this profile's windows and end its browsers, including
any nothing can reach
--exec CMD run CMD against this window, its output in the console
--slowmo MS pause MS between the actions of what --exec starts
--freeze hold the page where a driver failed instead of tearing it
down; alt+enter lets the run carry on
--grid show the grid whenever there is more than one page
--tmux-zoom grow the window to fill the pane while tmux has it zoomed,
and put its size back when the zoom ends
--search T the search a phrase becomes, as a url with %s where the words
go (default google)
--record F write what is done to the page to F as a Playwright spec,
until alt+r stops it
--profile N run in a profile of its own: its own logins, history and
browser, and none of the windows already up. "-" is a
throwaway one, removed on exit
--name N call this window N, so WEB_WINDOW=N picks it out for a
driver instead of its pid. Anything but a number
--port N fix Chrome's devtools port instead of letting it pick one
--no-pause keep drawing while the terminal is not focused
--hide-on-blur take the window off the screen while the terminal is
not focused, instead of leaving the last frame up
--no-media-pause let a video or a track in the page go on playing while
the terminal is not focused
--no-hover do not tell the page where the pointer is unless a button is
down
--no-graphics-check start even when the terminal does not answer the
kitty graphics question
--raw-keys let a key the page did not want reach the window system
--list lists the Chrome instances web has started:
$ web --list
pid 25495 up 04:11 port 53859
$ web --list
pid 50596 up 26:00 stranded - nothing can reach it
web: 1 stranded browser holding the profile; web --kill ends it
--kill ends them:
$ web --kill
web: stopping 1 window
web: stopping chrome 50596
web: window 15902 belongs to profile work; left running
--screenshot writes the page to a PNG and exits:
./web --screenshot shot.png example.com
./web --screenshot - example.com | pngtopam # "-" is stdout
echo 'document.querySelector("#accept").click()' |
./web --screenshot shot.png example.com--eval and piped stdin both run JavaScript in the page:
./web --eval 'document.title' example.com
./web example.com < check.js # a file needs no flag
echo 'document.links.length' | ./web --json example.comStdin is read a line at a time whenever it is not a terminal. Each line goes to
the page's eval, so it need not be an expression — the completion value is the
answer, as in devtools:
document.title
let n = document.links.length; n * 2
location.href = "https://example.org"
document.querySelector("h1").textContent
Values go to stdout, one per line, while the page goes to the terminal, so
./web example.com < s.js | jq works. --json wraps each value in an object
with the line that produced it. A line that throws prints to stderr and exits
non-zero.
A line that answers a promise is not finished until the promise is, so anything asynchronous can be a line of its own:
fetch("/api/status").then(r => r.json()).then(j => j.state)
The page is given verbs for waiting as __web. Each answers a promise, and
each gives up after --timeout unless told otherwise:
| Call | What it does |
|---|---|
__web.wait(sel[, ms]) |
the element, once it is there |
__web.gone(sel[, ms]) |
waits until nothing matches |
__web.until(fn or js[, ms]) |
waits until it answers something true |
__web.click(sel[, ms]) |
waits for it, scrolls to it, clicks it |
__web.type(sel, text[, ms]) |
waits for it, focuses it, types, fires input and change |
__web.text(sel) |
its text, trimmed, or null |
__web.all(sel) |
the text of every match |
__web.count(sel) |
how many match |
__web.attr(sel, name) |
one attribute, or null |
./web example.com <<'EOF'
__web.click("a")
__web.wait("h1").then(e => e.textContent.trim())
location.host
EOFA line that starts a navigation is finished when the page has arrived, so the line after it runs against the new one. A wait that gives up says what it was waiting for and the run stops, non-zero.
--delay MS paces the run, --step waits for a key between lines, --timeout S
caps one line.
: while reading, or ^X at any time, opens a line editor under the page;
either closes it. It has history, ^R search, and emacs kill bindings. esc
hands the keyboard back with the console still up, as does clicking the page.
What you type is JavaScript, evaluated in the page:
> document.querySelectorAll('a').length
42
> let seen = new Set(); for (const a of document.links) seen.add(a.host); [...seen]
news.ycombinator.com,github.com
> location.href = 'https://example.com'
Lines join the same queue --eval uses. Shift+Enter adds an input line, Page
Up/Page Down or the wheel scrolls the transcript, Enter runs. Dragging the top
border up or down resizes the pane, down to three rows and up to two rows short
of the whole window; the page takes back whatever it gives up. alt+y copies the
whole transcript, from inside the console or out of it — including whatever
--exec has printed there.
__web is here too, so __web.click(".titleline > a") waits for the link and
clicks it, and a line that answers a promise is answered when it resolves.
P while reading toggles picking: clicking the page writes the shortest CSS
selector for what you hit into the console instead of activating it.
--port pins the devtools port so Playwright can connect:
./web --port 9222 news.ycombinator.comconst browser = await chromium.connectOverCDP('http://127.0.0.1:9222');--endpoint reports every running window as one JSON object per line, without
starting a browser:
$ web --endpoint
{"pid":4123,"name":"hn","port":9222,"cdp":"http://127.0.0.1:9222","target":"0A32…","url":"https://example.com/","title":"Example Domain","handoff":true,"drive":"…/driving/4123","freeze":""}drive is the file a driver writes its own pid to at each step it takes. While
that file is fresh the window draws through a blur and leaves whatever is
playing alone. A driver that has not touched it for 30 seconds lets the window
pause on blur again; the next step it takes wakes it back up.
WEB_WINDOW picks one of them for a driver, by pid or by the name --name
gave it:
web --name hn news.ycombinator.comWEB_WINDOW=hn npx playwright test
WEB_WINDOW=hn node examples/drive.mjs
WEB_WINDOW=4123 npx playwright testA name is anything that is not a number, and holds for the life of the window.
The port names the browser rather than the page, so match the target id per page:
const browser = await chromium.connectOverCDP(cdp);
const ctx = browser.contexts()[0];
for (const page of ctx.pages()) {
const s = await ctx.newCDPSession(page);
const { targetInfo } = await s.send('Target.getTargetInfo');
if (targetInfo.targetId === target) { /* the page on screen */ }
}The package does that loop for you — see Playwright. Playwright
is not required: js/cdp.mjs is the same connection over raw CDP with no
dependencies, and examples/drive.mjs is written against it:
node examples/drive.mjs # against the one window running
web --exec 'node examples/drive.mjs' news.ycombinator.com@jhickner/web is this repository as a package. /test is @playwright/test
with page bound to the tab on screen, so a spec file is an ordinary spec file:
import { test, expect } from '@jhickner/web/test';
test('the front page lists stories', async ({ page }) => {
await page.goto('https://news.ycombinator.com');
await expect(page.locator('.titleline > a').first()).toBeVisible();
});npm i @jhickner/web @playwright/test
web --exec 'npx playwright test' about:blank # watch it run
npx playwright test # against a window already upexamples/fleet.spec.mjs is four pages at once, one per worker. From a
checkout, npm i @playwright/test and:
web --exec 'npx playwright test examples/fleet.spec.mjs --workers=4' about:blankFor a script of your own rather than a spec, attach() is the page:
import { attach } from '@jhickner/web/playwright';
const page = await attach();
await page.goto('https://news.ycombinator.com');
await page.locator('.titleline > a').first().click();| Import | What it is |
|---|---|
@jhickner/web/test |
test and expect, with page, context and browser on the window |
@jhickner/web/playwright |
attach(), connect(), pageFor() for a script of your own |
@jhickner/web/cdp |
endpoint() and page() over raw CDP, no dependencies |
- The first worker drives the window's own tab; every worker after it opens a
page of its own, which the window takes into the tab bar as it appears and
drops again when it goes.
--workers=4is four tabs, andalt+gis four tiles — one per worker, all running at once.--gridopens the grid by itself as soon as the second page appears. See The grid. - Playwright's unit of parallelism is the file: four tests in one file are one
worker however many
--workersallows, and so one tile.test.describe .configure({ mode: 'parallel' })in the file, orfullyParallelin the config, is what spreads them.examples/fleet.spec.mjssets it. - The tab is the context, so cookies, storage and the page itself carry from one test to the next; there is no fresh context per test.
- The viewport is the window's —
--cols,--rows,[and]set it, nottest.use({ viewport }). --slowmo 200puts a pause between actions.- A window being driven keeps drawing while the terminal is not focused, whether
--execstarted the run or a runner in another pane did. --freezeholds the page where a test failed — see Freezing.WEB_PROFILE=ci, or--profile ci, keeps a run off the browser you are using.WEB_WINDOWpicks the window when several are up:web --name hn <url>in one pane,WEB_WINDOW=hn npx playwright testin another.- A failing test attaches a screenshot of the page as it was left.
--freeze stops a failing test where it failed and holds the page there:
web --freeze --exec 'npx playwright test --workers=1' about:blank
web --freeze news.ycombinator.com # for a runner in another paneThe status line says FROZEN and the console says which test and why. Nothing
has been torn down: the console runs JavaScript against that page, P picks a
CSS selector off the element that was not found, f labels what was actually
clickable. alt+enter lets the run carry on to the next test.
The test's timeout is lifted while it waits. Only a window started with
--freeze freezes anything.
--mcp is an MCP server on stdio that drives the window already open, on
screen while it works:
claude mcp add web -- web --mcp| Tool | |
|---|---|
snapshot |
everything on the page that can be acted on, each with a ref |
click |
by ref, or by CSS selector |
type |
into a field, as real key input; submit presses Enter after |
press |
Enter, Tab, Escape, Backspace, Delete, arrows, PageUp, PageDown, Home, End |
navigate |
an address in the tab on screen |
new_tab |
an address in a new tab of the window |
back / forward |
the tab's own history |
read |
the page as text |
eval |
JavaScript in the page; promises are waited for |
wait |
until a JavaScript expression is true |
screenshot |
a picture of the page |
The window keeps drawing while an agent works it, whether or not its pane is
focused. WEB_WINDOW picks one by pid or by --name when several are up. With
--record, what the agent does is written down as a spec.
--record writes what you do to the page as a Playwright spec:
web --record login.spec.ts example.comalt+r starts and stops it, and names the file web-DATE-TIME.spec.ts when
--record did not. Clicks, typing, dropdowns, tick boxes, enter, back and
forward, and every address opened — typed in, or commanded by a driver over
CDP, Playwright or --mcp — are recorded. The status line says REC.
Locators are picked in this order:
| Locator | When |
|---|---|
getByTestId |
data-testid or data-test-id |
getByRole |
one element has that role and accessible name |
getByPlaceholder |
|
getByLabel |
|
getByAltText |
|
getByText |
one element has that text |
locator |
nothing above is unique: shortest CSS path |
The file is rewritten after every step, and runs at any point in a recording:
import { test, expect } from '@jhickner/web/test';
test('recorded', async ({ page }) => {
await page.goto('https://example.com/login');
await page.getByRole('textbox', { name: 'Email' }).fill('me@example.com');
await page.getByPlaceholder('Password').fill('hunter2');
await page.getByRole('button', { name: 'Sign in' }).click();
});npx playwright test login.spec.ts runs it back — see
Playwright.
--exec starts a program with the endpoint in its environment and puts its
output in the console while the page stays live above it:
web --exec 'node examples/drive.mjs' example.comThe child gets WEB_CDP_URL, WEB_CDP_PORT, WEB_TARGET_ID, and
WEB_PROFILE when the window is in a named one. Quitting the
window ends it; it ending leaves the window. Both streams go to the console.
To go the other way, start the browser from Playwright and take it over:
const browser = await chromium.launch({args: ['--remote-debugging-port=9222']});Then attach 9222, typed in the console or run from a script:
- The browser
webstarted is shut down unless--keepsaid otherwise. The one it attached to is never shut down; quitting leaves it running. --keepis asked of the browser, not the run: while any window has asked for this browser to stay, no window's quit shuts it down. The request dies with the browser.- The device metrics override goes on their page too, so Playwright sees the viewport the frames are drawn at.
--port Nagainst a browser already answering there takes it over rather than starting a second. With no address it leaves that browser on whatever page it is on.- A browser one of ours started is the exception: a later
--portat it takes a tab of its own, and can shut it down like any other run.
[ and ] change the viewport width rather than magnifying pixels, so the
page reflows: text gets larger and responsive sites drop to their narrow layout.
A page that cannot reflow that narrow gets its viewport widened back until it
does. The status line says so — zoom 77% - page needs 1240px — and the stored
zoom drops to what the page allowed.
alt+f turns fitting off if you would rather have the magnification and scroll
sideways with h and l. alt+0 resets to 100%.
A width pinned with w or W is exempt and honoured whether the page fits it
or not. The measurement still runs — width 360px - page needs 980px — and what
does not fit is reached with h and l.
| Variable | Effect |
|---|---|
WEB_CHROME |
path to a different Chrome build |
WEB_PROFILE |
the profile to run in, as --profile names it. Set for what --exec starts |
WEB_WINDOW |
which window a script attaches to, by pid or by --name, when several are up |
WEB_CELL |
WxH cell size, if the page aspect looks stretched. A terminal reporting no pixel geometry leaves it a guess (8x17 default); a trace (^D) records which you have |
Chrome ──Page.screencastFrame──> base64 PNG ──> kitty graphics ──> terminal
^ |
└────────── Input.dispatchKeyEvent / dispatchMouseEvent ───────────┘
Frames pass through as base64 PNG, never decoded or re-encoded.
Scrollbars are turned off and scrolling is one jump rather than an animation.
make browser installs web and builds ~/Applications/Web.app, an
http/https handler that hands the link on:
make browserThen System Settings > Desktop & Dock > Default web browser > web, and confirm the prompt macOS puts up.
A link opens as a tab in the web window most recently used, which selects its
own tmux pane if it is in one. With no window running, it starts one outwards
from whatever is already on screen:
| Where | What happens |
|---|---|
| a free pane of the tmux window on screen | web is run in it |
| no free pane there | a split of that same window |
| no room left to split | a tmux window of its own, in that session |
| no tmux | a tab of the Ghostty window already open |
| no Ghostty | a window of its own |
A pane is free when nothing but a shell is running in it, and the active pane is taken first. The tmux window on screen is the one the client active last is showing. The terminal is brought forward either way.
sh mkbrowser.sh --terminal kitty # some other terminal
sh mkbrowser.sh --no-activate # leave the terminal where it is
sh mkbrowser.sh --to /Applications # somewhere other than ~/Applications--open is the same handoff from a shell, and is what the bundle runs:
web --open https://example.comIt exits non-zero when there is no window to hand to. A window started by a
web older than --open is never handed to.
