Skip to content

Repository files navigation

web

A terminal split into panes, with web pages, a YouTube video, source code, and a shell all drawn side by side

Chrome in your terminal!

  • runs headless chrome, and streams it into kitty graphics windows in your terminal (including tmux).
  • --login opens a real window with web'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

Quick start

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.com

Or from source:

make
./web news.ycombinator.com

Each address after the first opens in a tab; the window starts on the first:

./web news.ycombinator.com lobste.rs example.com

An 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 all

Logins

Opens a normal browser window on the same profile. Log in to whatever sites you need and web keeps the session:

web --login

Profiles

Every 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 exit

A 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.

Keys

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+1alt+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

Vim keys

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.

Links without a mouse

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.

Site rules

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.

Extensions

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/ubolite

extensions = no in web.conf leaves them all alone. Installing from the Chrome Web Store through --login does not carry to this window.

The grid

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+1alt+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.

Finding a page

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

~/.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 f1f12. ^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.

Options

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

Screenshots

--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

Scripting

--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.com

Stdin 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
EOF

A 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.

The console

: 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.

Remote control

--port pins the devtools port so Playwright can connect:

./web --port 9222 news.ycombinator.com
const 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.com
WEB_WINDOW=hn npx playwright test
WEB_WINDOW=hn node examples/drive.mjs
WEB_WINDOW=4123 npx playwright test

A 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

Playwright

@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 up

examples/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:blank

For 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=4 is four tabs, and alt+g is four tiles — one per worker, all running at once. --grid opens 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 --workers allows, and so one tile. test.describe .configure({ mode: 'parallel' }) in the file, or fullyParallel in the config, is what spreads them. examples/fleet.spec.mjs sets 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, not test.use({ viewport }).
  • --slowmo 200 puts a pause between actions.
  • A window being driven keeps drawing while the terminal is not focused, whether --exec started the run or a runner in another pane did.
  • --freeze holds the page where a test failed — see Freezing.
  • WEB_PROFILE=ci, or --profile ci, keeps a run off the browser you are using.
  • WEB_WINDOW picks the window when several are up: web --name hn <url> in one pane, WEB_WINDOW=hn npx playwright test in another.
  • A failing test attaches a screenshot of the page as it was left.

Freezing

--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 pane

The 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

--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.

Recording

--record writes what you do to the page as a Playwright spec:

web --record login.spec.ts example.com

alt+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

--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.com

The 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.

attach

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 web started is shut down unless --keep said otherwise. The one it attached to is never shut down; quitting leaves it running.
  • --keep is 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 N against 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 --port at it takes a tab of its own, and can shut it down like any other run.

Zoom and width

[ 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.

Environment

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

How it works

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.

Default browser

make browser installs web and builds ~/Applications/Web.app, an http/https handler that hands the link on:

make browser

Then 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.com

It exits non-zero when there is no window to hand to. A window started by a web older than --open is never handed to.

About

Chrome in your terminal: a live browser window with tabs, streamed inline with the kitty graphics protocol.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages