Euler source The code that runs the solver shell and this site, version 4.34.2 Repository on GitHub

The shell → browser control channel: OSC 5379.

208 lines of Python Raw file Open on GitHub

#!/usr/bin/env python3.14
# -*- coding: utf-8 -*-
"""The shell → browser control channel: `OSC 5379`.

A **web** shell runs on a PTY whose bytes are a WebSocket away from the page that
frames it (:mod:`euler_webshell.ws`), so a command can speak to the browser chrome by
writing a control sequence into its own stdout: `ESC ] 5379 ; <payload> BEL`.
The counterpart is `packages/site/euler_site/content/assets/terminal.js`'s
`registerOscHandler(5379, …)`, which parses the payload and asks its parent page
to act — the shell drives the chrome, never the other way round.

Every payload starts with this shell's **key**, then the action: `<key>;<action>;…`. The
per-user service draws the key when it forks the shell, hands it over in the environment
(`EULER_OSC_KEY`, taken back out at import so nothing the shell runs inherits it) and gives
it to the page on attach. The page ignores a sequence without it, so text that merely
*contains* `ESC ] 5379` — a file being printed, `eval` output, a merged commit message —
cannot drive the chrome.

The payloads after the key, one per action — the first field is always the action, and every
payload carries a *token* (:func:`token`):

- `open;<NNNN>;<token>` — show problem *NNNN* in the left pane.
- `edit;<NNNN>;<token>;<relpath>` — open *relpath* in the pane's editor. The
  token sits *before* the path because a relpath may itself contain `;`: it is
  the one field that must stay last and be rejoined by the reader.
- `nav;<token>;<path>` — swap the left pane to `/<path>`, any site route (`show <path>`).
  The path is relative (no leading `/`) and last, rejoined like `edit`'s.
- `git;<token>` — this clone's git state changed; re-read the header's chip.
- `msg;<token>` — this user's mailbox changed (they read, sent, replied or dismissed);
  re-read the header's message chip.
- `account;<token>` — this user's account state changed (a new identity, a GitHub
  sign-in); re-read the account page — *if it is the visible pane*.
- `progress;<token>` — `problems.json` changed (`summary`, `mark`); re-read the
  solutions grid — *if it is the visible pane*.
- `logout;<token>;<seconds>` — the user ran `logout`: draw the notice, count *seconds* down and
  sign out (:mod:`euler_webshell.shell.logout`).
- `busy;<token>` / `ready;<token>` — the shell has lent the terminal to an interactive child
  (`! sh`, `! py`, `claude`, …) / taken it back (:func:`handoff`). Unlike the rest these are a
  **state**, not an act: while busy the page obeys no other action and disables every control
  that types a command, since the keystrokes would land in the child, not the shell.

- `running;<token>` / `idle;<token>` — the shell left its prompt to run a block / came back to
  it (:func:`working`). Also a **state**, but the page has no use for it: the per-user service
  reads it from the PTY stream (`ws/manager.py` § `TermModes`), which it sees with no tab open.

The token is a server-side millisecond clock, strictly increasing per command. On
attach the service replays the scrollback, which redraws the sequences of commands
that already ran; the token lets the browser tell a replay from a fresh act and obey
only the latter (`terminal.js`, § the OSC handler). `busy` / `ready` are the exception:
a reattach mid-subshell must come back disabled, so the page rebuilds that state from
the replay too, the last of the pair winning.

This module is the one definition of that wire, and it lives on the web side: the solver
shell knows nothing of it. The *state-changed* payloads are sent by :func:`nudge`, the web
shell's `notify` — `overlays.toml` names the commands after which each one fires
(docs/package-guide.md § 3.2) — and the pane-swapping ones by the web shell's `show` / `edit`
(:mod:`euler_webshell.shell.viewer`). The message commands, web-side themselves, emit directly;
`busy` / `ready` are the web shell's `handoff` (:class:`~solver.shell.extension.Extension`).
"""
from __future__ import annotations

__all__ = ['EVENTS', 'OSC_CODE', 'OSC_KEY_ENV', 'account_changed', 'emit', 'git_changed', 'handoff',
           'logout', 'messages_changed', 'nudge', 'progress_changed', 'token', 'working']

import os
import sys
import time
from typing import Callable

from euler_webshell.ws.pty import OSC_KEY_ENV
from solver.config import config

#: The OSC identifier shared with `terminal.js`'s `registerOscHandler`.
OSC_CODE: int = 5379

#: This shell's key, popped from the environment on import so the commands the shell runs
#: (`eval`, `!`) never inherit it. Empty off the web channel, where nothing is listening.
_KEY: str = os.environ.pop(OSC_KEY_ENV, '')


def token() -> int:
    """A strictly increasing per-command stamp: the wall clock in milliseconds.

    Exposed because the token's *position* is part of each payload's shape (`edit`
    keeps its relpath last), so the caller places it — but no caller invents its own
    clock.
    """
    return time.time_ns() // 1_000_000


def emit(action: str, *fields: str) -> None:
    """Write `ESC ] 5379 ; <action> ; <fields…> BEL` to stdout — web channel only.

    A no-op off the web channel: a terminal shell's stdout is a real terminal, and an
    escape sequence no one is listening for would just be noise in it. Callers
    therefore need no channel test for the *emission* — only for the user-facing
    difference (the viewer opens a named browser tab instead).
    """
    if config.subject.channel != 'web':
        return
    sys.stdout.write(f'\x1b]{OSC_CODE};{";".join((_KEY, action, *fields))}\x07')
    sys.stdout.flush()


def git_changed() -> None:
    """Tell the page this clone's git state moved: the header re-reads its chip.

    Emitted after the commands that can change what the chip shows — a commit, a sync,
    a push, a wired filter, a regenerating verb's commit (`overlays.toml`).
    The header reads its state once per navigation and never polls, so without this
    the chip would be at its most wrong exactly after the user acted. It is only a
    *nudge*: the browser answers by re-reading `/git`, so a lost or replayed
    sequence costs at most a stale chip until the next navigation, never a wrong one.

    Because this rides the shell's own stdout, it fires for a hand-typed `git-sync`
    exactly as it does for the header menu's item — the menu types the same command,
    so there is one path, not two.
    """
    emit('git', str(token()))


def messages_changed() -> None:
    """Tell the page this user's mailbox moved: the header re-reads its message chip.

    Emitted by the `msg` command on every path that changes what the chip shows —
    reading a thread (the unread count drops), sending, replying, dismissing.

    This is the **shell's** half of a two-sided nudge, and the distinction matters. When
    a message *arrives*, the sender's spool pushes to the recipient's instance and the
    page hears it as a WebSocket text frame (web-server-guide § Messaging): the browser
    could not have known, because someone else acted. When the user *reads* one, the act
    happened in their own shell — no service saw it, and a round trip out to the spool and
    back to say so would be inventing a second mechanism for something stdout already
    carries. Both land on the same `euler:message` body event, so the chip has one
    listener and does not care which side moved.

    Without it the chip is at its most wrong exactly after the user acted: they read the
    one unread thread and the badge still says 1 until the next full page load.
    """
    emit('msg', str(token()))


def account_changed() -> None:
    """Tell the page this user's account state moved: refresh the account panel.

    Emitted after the commands that change what the account page shows about *you* — a
    new key pair (`user`), a GitHub sign-in (`git-identity`). The nudge reaches
    the page the same way `git;` does; the difference is on the browser side, where
    the listening element lives **inside** the account fragment, so it re-reads only
    when the account page is the visible pane and is a no-op otherwise (web-server-guide
    § The site). The chip's own git state is a separate concern: `git-sync` /
    `git-filter` change decrypt access too, but they emit :func:`git_changed`, and the
    account panel listens for that event as well — so those need not emit both.
    """
    emit('account', str(token()))


def progress_changed() -> None:
    """Tell the page `problems.json` moved: the solutions grid re-reads itself.

    Emitted after `summary` and `mark`, which re-write the progress file. It matters most for
    the web upload, which stores the pasted page and hands the import to `summary` in
    this terminal — so the grid the upload left on screen is stale until the shell
    finishes, and this is how it learns that it has. Like `account;`, the listener
    lives inside the pane (`solutions.html`), so it is a no-op unless the grid is the
    visible page.
    """
    emit('progress', str(token()))


#: The state-changed events an overlay may emit, each to the function that sends it.
EVENTS: dict[str, Callable[[], None]] = {
    'git': git_changed,
    'msg': messages_changed,
    'account': account_changed,
    'progress': progress_changed,
}


def nudge(event: str) -> None:
    """The web shell's `notify`: send the *event* named in an overlay (:data:`EVENTS`)."""
    EVENTS[event]()


def logout(seconds: int) -> None:
    """Ask the page to count *seconds* down and sign out: only the browser holds the cookies."""
    emit('logout', str(token()), str(seconds))


def working(running: bool) -> None:
    """The web shell's `working`: `running` as a block starts, `idle` back at the prompt.

    How the service tells work from an idle prompt without typing into the terminal: a probe
    written to the PTY would land in whatever holds it. Waiting counts as work, so an idle
    `claude` or an open dialogue reads as running, and a job left in the background does not.
    """
    emit('running' if running else 'idle', str(token()))


def handoff(taken: bool) -> None:
    """The web shell's `handoff`: `busy` as an interactive child takes the terminal, `ready` after.

    While a subshell, Python or Claude Code owns the PTY, a header verb would type its command
    into *that* program — `git-sync` at a bash prompt, or into a Python REPL. So the page is told,
    and greys those verbs out until the shell has the terminal back. The child cannot fake the
    `ready`: the key left the environment at import, so nothing the shell runs knows it.
    """
    emit('busy' if taken else 'ready', str(token()))