Skip to content

Cooperative multi-app - #15

Merged
alganet merged 20 commits into
mainfrom
coop
Jul 17, 2026
Merged

Cooperative multi-app#15
alganet merged 20 commits into
mainfrom
coop

Conversation

@alganet

@alganet alganet commented Jul 17, 2026

Copy link
Copy Markdown
Owner

No description provided.

alganet added 20 commits July 11, 2026 03:12
Region safety. tuish_clear_to_eol is ESC[K — erase to the end of the PHYSICAL
line, with no region clamp — and every example we newly made hostable called
it, so an embedded app erased its host's chrome: the hosted editor in
cooperative.sh emitted ESC[K at the region's left column and wiped through to
the terminal edge, eating the editor box's own right border. Add a bounded
tuish_clear_to_edge ROW [COL] and switch the examples to clear-then-print. The
editor had three more of the same family — its mode toggle, resize handler and
reverse-video status bar all sized themselves to TUISH_COLUMNS, so in a region
the status bar painted a full-terminal-width bar across the page; they use
TUISH_VIEW_COLS now. The raw ESC[2J/2K/K/1K primitives stay for root-owned
apps and are marked not-region-safe.

Idle-tick negotiation. A driven child's tuish_idle_interval was inert: the
HOST's loop owns the idle wait, so a mounted game asking for 0.02s silently ran
at its host's 0.26s. Children can want different clocks, and one loop has one
poll rate, so two rules pull in opposite directions — the fast child must not be
slowed, the slow one must not be sped up. tuish_ctx_sync_interval adopts the
fastest tick among the host and its children; tuish_ctx_tick accumulates that
tick per child and only drives it once ITS OWN interval has elapsed. Over 2.0s
of virtual time a 50Hz child gets 100 idles and a 1Hz child gets 2. Replaces
TUISH_MOUNT_INTERVAL, which only ever half-wired this.

Dead code. The _tuish_yield/_x/_y trio was written and read by nobody: the coop
router handles out-of-region mouse by not forwarding it, and the modal path's
behaviour came entirely from the tuish_quit call. Drop them (and the unused
tuish_ctx_deactivate) and correct the comments that described a re-dispatch that
never existed.

Duplication. The region-reseat both cooperative.sh and the website hand-copied —
each admitting as much in a comment, while poking ten private fields — is now
tuish_ctx_reseat.

Tests. test_hosting's scroll assertion was vacuous: the panel shows six rows
from the top, so its three j presses never scrolled and R03 was already on
screen. It now scrolls past the fold. New test_context.sh (21 assertions) pins
region seating, the bounded erase, and the negotiation.

Docs. The whole context/hosting/cooperative surface was undocumented and six
pages made claims that are now false (callbacks as override-only, TUISH_MOUSE_X
as a raw terminal column, vmove as row-only, VIEW_COLS == COLUMNS, fini as
"restores the terminal", a flat bind table). Add docs/hosting.md and correct
them.
Groundwork for live documentation — doc pages that embed RUNNING examples, and
snippets you can edit in place with editor.sh.

CLIPPED REGIONS. A live widget in a scrolling document has to slide under the
pane's edge. tuish_vmove already clips per cell, but _tuish_ctx_seat conflated
three separate things: the child's layout size, its origin, and what it may draw
through. Split them. A child can now be seated with its origin ABOVE the pane and
a clip window covering only the visible slice: it keeps thinking it is full size,
so it does not reflow — it is genuinely occluded. tuish_ctx_reseat takes an
optional clip window to do this.

tuish_canvas OVERWROTE the transform's clip bounds instead of intersecting them
with the context's base. A canvas could therefore draw outside its host's region
entirely — a latent escape on main, not just under the new clipping (the new test
fails three ways against the old canvas.sh). It intersects now; the two clips live
in different coordinate spaces, so it is a conversion, not a min/max.

PASTE. tuish recognized only the ESC[200~ / ESC[201~ markers and let the body fall
through to the key decoder — so a paste arrived as a burst of key events: a pasted
newline fired the app's `enter` BINDING, a tab its `tab` binding (expanding to four
spaces), and every character forced its own render+flush. _tuish_capture_paste
consumes the body instead and delivers one atomic `paste` event with the text in
TUISH_PASTE. The markers still fire, so apps that track them keep working.

CLIPBOARD. New src/clip.sh: OSC 52 copy-out, with a fork-free base64 built on
ord.sh's tables (verified byte-for-byte against coreutils, including UTF-8 and
every padding case). Clipboard READ is deliberately absent — terminals and browsers
block it, and inbound text is bracketed paste's job. editor.sh gains the
selected-text extractor it never had (selection could only be rendered or deleted),
an internal register, ctrl-c/ctrl-x/ctrl-v, and atomic multi-line paste insertion.
Its hard-coded buffer name is now _ed_buf, and _ed_text serializes the buffer, so a
host can seed the editor with a snippet and read the edit back.

ord.sh built its char table with $(printf ...) on busybox/ksh93/mksh, and command
substitution strips TRAILING NEWLINES — so _tuish_chr_10 was the empty string on
exactly those shells. Nothing read it until paste capture needed a newline, at
which point pasted line breaks vanished on busybox, the browser/wasm target. A
sentinel byte fixes it. The tests assert the character is real first, because
otherwise every newline assertion compares '' to '' and passes vacuously.
Follow-ups from putting live widgets in a scrolling document.

tuish_ctx_mount does not merely create a context — it RUNS the child's setup and
paints it. So a widget mounted while partly scrolled past its host's pane drew
over the host's chrome once, before any tuish_ctx_reseat could bound it. Add
TUISH_MOUNT_CLIP: a clip window applied before the child's first paint.

tuish_ctx_render repaints a mounted child on demand. A host that MOVES a child
(scrolling) needs it redrawn into its new rectangle now, not on its next idle
tick — at a lazy interval that leaves a torn widget on screen for a whole tick.

boxes.sh called tuish_vmove and then printed REGARDLESS of the result. vmove
refuses a clipped cell, and printing anyway drops the text wherever the cursor
last sat — standalone that almost never bites (a cell is refused only
off-screen), but in a clipped region it fires constantly and smears stray labels
across the host's chrome. A row-bounds test is no substitute: TUISH_VIEW_ROWS is
the layout height, which stays full-size while the visible clip shrinks. Only
vmove knows. All of them now go through one guarded helper; the standalone render
is byte-identical to its fixture.
_ed_buf becomes a per-context field, so a host can mount editor.sh on a buffer it
already populated — a documentation snippet — and read the edit back, without two
mounted editors fighting over one target.

This is what makes a doc snippet editable: the snippet's text already lives in a
line buffer, and a line buffer is precisely what editor.sh edits. "Edit this
snippet" is then just "mount the real editor on that buffer".
hosting.md: how a live child scrolls under a pane edge — a region is three
independent things (layout size, origin, visible clip), and only keeping them
apart lets a child be occluded rather than reflowed. Plus TUISH_MOUNT_CLIP (a
mount PAINTS, so a child must be clipped before its first paint), tuish_ctx_render,
and the rule a clipped app has to follow: honour tuish_vmove's return value, since
it refuses a clipped cell and printing anyway smears text across the host's chrome.

hid.md: the `paste` event. The body is not delivered as keystrokes — it is text,
and feeding it to the key decoder fires the app's `enter` and `tab` BINDINGS and
renders once per character.

clip.md: OSC 52 copy, why there is deliberately no clipboard READ (terminals refuse
it and browsers block it — it would let anything in your terminal exfiltrate what
you last copied), and the consequence: an app that wants copy AND paste within
itself must keep its own register.
A host that routes the mouse by position gets the wheel wrong: park the
pointer over an embedded widget that scrolls nothing and the page stops
scrolling entirely — and never recovers, because nothing then moves the
widget out from under the pointer.

tuish_dispatch now marks an event handled BEFORE running the action, so an
action that turns out to be inert can hand it back with tuish_pass. A host
reads the result as TUISH_CTX_HANDLED after tuish_ctx_dispatch and chains
what the child declined, the way a nested scroller does in a browser.

The editor's wheel bindings pass at the top and bottom of their buffer (and
stop reading a stale TUISH_BUF_COUNT from whatever counted last), and its
catch-all passes instead of silently eating every event a host offers it.
_tuish_buf is a PER-CONTEXT frame, so a host cannot buffer everything that
happens: the moment it activates a child it is looking at the child's
buffer, and the child's own tuish_end goes straight to the terminal. A page
with three live widgets emitted four writes, and the terminal drew each one
— the prose landed at its new scroll offset a frame before the widgets did,
which reads on screen as a shimmer trailing the text.

tuish_ctx_render now splices into the host's buffer when the host has a
frame open, and tuish_ctx_mount holds the child's first paint the same way,
so mounting a widget on a click does not flash it up a frame early. Both
fall back to writing for themselves when no host frame is open. Every flush
goes through _tuish_sink, which is the one place that can hold them.

Measured on the site: 3 writes per scroll tick -> 1, and 2 -> 1 on a click
that mounts an editor.
tuish_begin unconditionally reset the buffer. But the framework opens a
frame BEFORE it calls your code and puts things in it — the caret hide that
precedes every deferred render — so any app that buffered inside its own
render handler silently threw those away. Every host does. That is why the
caret stayed on, blinking wherever the last cell was drawn.

_tuish_buffering is a depth now: only the outermost begin clears and only the
outermost end writes. tuish_end below zero clamps rather than underflowing, and
the dispatcher restores the depth it entered at, so one unbalanced app costs a
frame instead of wedging the loop into never flushing again.

It repairs the splice, too: a child whose own render used begin/end was
flushing straight to the terminal mid-host-frame, and the host's background
fill then landed on top of it. tuish_ctx_render now also HOLDS, which catches
children that tuish_flush inside their render (canvas_demo does).

TUISH_MOUNT_CLIP — a write-once global, consumed by the next mount — becomes
tuish_ctx_clip R C W H: a host declares the pane its children are seen through
once, and mount and reseat both honour it. The two spellings for one concept
are gone, and so is forgetting one of them, which was a silent one-frame
corruption.
The website grew a slot engine — a child table, mount/reseat/unmount on
scroll, clipping, focus, mouse routing, per-child ticks — and none of it was
in the toolkit. cooperative.sh had independently hand-rolled the same ideas
and got less of it right: its own rect test, its own per-child mouse routing
unrolled, its own tick loop, its own reseat-on-resize, and no focus model at
all (the keyboard was wired to the editor).

host.sh takes the child table and the rules. A host declares its children
(begin/slot/commit) and says where they go; host.sh reconciles. Three things
it gets right that a hand-rolled host does not:

  - the id is NOT the rectangle, so scrolling reseats children rather than
    remounting them — and a mount paints, so remounting would redraw the page
    widget by widget before the real repaint even started;
  - the FOCUSED child paints last, which is the only reason the caret ends up
    where you are typing;
  - an event a child declines comes back to the host (scroll chaining).

cooperative.sh's router is now three lines and it gained focus for free.

Catch-alls that bound ':' silently ATE every event a host offered — the swallow
bug that froze the site's scrolling. They pass now.
game.sh registers no render handler: it is real-time, so it paints every
frame directly rather than through the coalescing rAF path (which would
freeze it while a key is held). But tuish_on_redraw is also how a HOST
repaints a child — when it assembles a frame, and when it moves one — and an
app that registers none paints nothing where the host put it.

Standalone that never showed. Hosted, the platformer scribbled its sprites
over whatever the page had left on screen, and the reader saw a level with no
level in it.

Register a handler that forces a full repaint, and say so in hosting.md: a
render handler is not optional, even if you never ask for a redraw yourself.
A widget narrower than its pane leaves columns beside it that belong to the
host, and two widgets side by side leave a gap between them that does too.
tuish_host_row_span answered with the outer bounds of the children, which
cannot express that gap: the host never repaints it, and last frame's text
just stays there. That is the bug the span was added to fix, one case over.

So ask the question the caller actually has. tuish_host_row_free ROW C W
hands back the runs of the row nobody is standing on, as "C W" pairs, spent
straight on tuish_draw_fill. However many children share the row, whatever
the gaps between them.

tuish_host_focus, meanwhile, was the only function in the toolkit that
returned its answer by PRINTING it — and a shell has no way to read that
back but a subshell. Every host forked to ask who was focused: once per idle
tick in the website, once per rendered frame in cooperative.sh, in a toolkit
whose whole argument is that it does not fork. Focus is state, not a query:
it is TUISH_HOST_FOCUS now, read like TUISH_EVENT or TUISH_MOUSE_X.

Underneath, the same clipped-rectangle scan was written three times and the
id lookup three more, so the mouse router walked the child table three times
per click. One _th_rect, one _th_find, and the pane kept in fields instead of
re-parsed out of a string by two functions that had drifted apart.

And three things that were quietly wrong: `modal` was documented as 1 and
compared against the word, so the documented spelling produced a child that
was not modal; tuish_host_pane with no arguments stored a rectangle of
spaces, which is not empty, so every later query parsed blanks and compared
them as numbers; and host.sh was the only module with no load guard, where a
second source would reset the child table out from under its children.

37 host tests, up from 20. The one that matters asserts the gap between two
children is the host's to paint.
…es not own

One process, one set of global shell variables, one terminal — and now
several independent contexts living inside all three. Everywhere the
singular substrate leaked through the per-context abstraction, it did so
as a silent corruption. Three of those leaks, closed.

`local` is a lie on ksh93, and it is the expensive kind. There the alias
is `typeset`, which scopes only in a ksh-STYLE function; every function
here is POSIX-style, so every `local` in tuish is really a GLOBAL. A
framework helper's scratch variable overwrites any caller variable that
shares its name. It was not hypothetical: `_base` in the event loop
overwrote the driver's own `_base`, turning `E 91 49 …` into `E 91 1 …`
on the second iteration and taking 176 modifier tests with it; draw.sh's
`_top` overwrote a host's layout row and handed "╭────────╮" to
tuish_text as a coordinate. compat.sh now re-declares every framework
function ksh-style after the fact (`typeset -f`, one fork, not one per
function) so `local` finally scopes — with a load-bearing skip list for
the trap path, the reflective name-deref helpers, and the blocking loop,
each of which a ksh scope would break. The functions that must stay
POSIX get namespaced locals instead (`_tuish_`-prefixed), because a
framework local is a framework global on one of our five shells; name it
like one. test_scope and test_namespace pin both halves.

An app's state is plain globals, and a shell has exactly one of each, so
mounting the editor twice meant two widgets sharing one cursor.
tuish_ctx_declare lets an app declare its state as a field SET at source
time; each mounted instance gets its own copy, marshalled with the
context. examples/twins.sh is the proof — the same editor mounted twice,
each with its own buffer and caret. test_fields covers it.

And the device is singular where the region is not. `_tuish_hosted`
conflated "I draw into a region" (true for everyone, including the root)
with "I own the terminal" (true for exactly one context) — so a child
unmounted from a rectangle it never owned would put the alt-screen, the
mouse mode, or the scroll region back for EVERYBODY. It is `_tuish_owns_dev`
now, and only the framework branches on it; `tuish_hosted` stays as the
documented predicate. Device state that an app REQUESTS and only the
device layer restores: the mouse already gated this way, and detailed
mode and the caret shape now join it. The caret's shape rides with the
caret and is re-asserted every frame, so a child mounted inside a host's
event handler — whose frame the rAF path discards — no longer loses its
`ESC[6 q` into a buffer that was never written (a thin bar in the
terminal, a fat block on the website, from one line of code). event.sh
forgets the shape cache wherever it throws frame content away, so it can
never claim bytes that never landed. The root seats itself too now: its
region is the whole screen, refusing the malformed escapes it used to
wave through for out-of-bounds rows and columns. test_device draws the
line between the two kinds of state.

REPORT.md (a review of this branch) is intentionally left untracked.
_tuish_raf_inhibit says "do not peek at the input right now: the next sequence's
ESC byte has already been read, so a peek would eat its body". It was a context
register, and it says nothing about any context — there is one input stream, one
_tuish_pending_byte, one reader.

The bug that hides there needs a host to show itself, which is why the escape-burst
tests never caught it. tuish_run raises the flag in ITS context mid-sequence, then a
cooperative host routes the event onward with tuish_ctx_dispatch — which activates the
child, whose saved frame carries its own copy of the flag: 0. The child requests a
redraw, reaches the rAF check, sees no inhibit, and peeks. That peek stashes a byte in
_tuish_pending_byte while tuish_run's escape loop is reading the tty directly, so the
sequence body it was assembling loses a byte to a replay queue the loop will not drain
until later. The bytes come back out reordered: exactly the failure the flag exists to
prevent, entered through the front door.

Same reasoning as TUISH_HANDLED, one line below it: state that describes the DEVICE, or
the single event in flight, must not be marshalled per context. The redraw REQUEST stays
per-context — a child's pending frame is not its host's — and the tests now pin both, so
the two cannot drift back together.
tuish_request_redraw defers the render while input is pending, which is right for an
editor and coalesces an autorepeat burst into one paint. But "input is pending" is a -t0
peek: it does not say a burst is coming, it says we are BEHIND — a byte is already
buffered. Deferring on that with no bound is a livelock. Once a frame costs more than the
terminal's autorepeat interval, bytes queue faster than they drain, the peek never comes
back empty, and the screen is withheld until the key is RELEASED. Every app has this;
game.sh only meets it first, which is why it opted out of the scheduler and paints by
hand.

Two clauses fix it.

An IDLE never defers. Idle MEANS the input was exhausted — the reader let a whole interval
elapse with nothing arriving to produce the event — so a byte landing while the handler
runs does not undo the wait already paid. It is also the safest place to render: not
testing means not peeking, and the peek is the only thing there that can reorder a byte.

And the deferral gets a budget: hold at most TUISH_DEFER_MAX events, then paint regardless.
The budget is a trade, and the direction that bites is the unobvious one — each forced
render costs a frame the backlog must chew through, so too SMALL a budget drains slower
than input arrives, the queue grows without bound, and letting go of the key leaves the app
still acting on it a second later. It must stay above autorepeat_rate x frame_cost; 8
carries a 266ms frame against VT's ~30/s, and around 4 it inverts.

The condition asks _class rather than latching TUISH_EVENT_KIND before the handler. The
latch cost ~8% of every dispatch, measured, to answer a question only a pending redraw ever
asks — and _class is the better answer anyway: it is the descriptor we were woken by, where
TUISH_EVENT_KIND is a context field holding whatever the handler last left there.
An idle event is not a timer. It is the reader's `read -t<interval>` timing out — so the
tick fires while a key is held if and only if the idle interval is SHORTER than the
terminal's autorepeat interval. Above it a byte is always waiting before the timeout can
land, the read never times out, and a real-time app's clock stops dead for as long as the
key is down.

Nothing stated that constraint, and the margin is thinner than it looks: the platformer
polls at 20ms against a typical ~33ms autorepeat, so it survives on 13ms of slack. `xset r
rate 250 50` is a common setting and erases it outright — hold a movement key and the
player takes one step and freezes until release, standalone and hosted alike, with nothing
in the code to say why. The demo's own claim that it "plays at the same speed for any
TUISH_IDLE_TIMEOUT" is true for gravity and false for a held key, where a coarse enough
interval means no movement at all.

So tuish_run counts complete events since a read last timed out, and injects the tick the
reader is not delivering. Healthy interleaving is `idle, byte, idle, byte`: the count never
passes 1, nothing is injected, and the clock is exactly what it always was — that case is
pinned by a test, because a guarantee that taxes the healthy path is not worth having.
Reaching 2 means two events with no timeout between them, which IS a starved tick, so we
owe one. A threshold of 1 would fire after every byte and double the rate.

The check sits where the loop is between complete events, before the escape FSM starts
reading a body — an idle injected mid-sequence would split an ESC from its parameters. It
counts events, not bytes, so a held arrow banks credit like a held letter.

This buys LIVENESS, not fidelity: under starvation the tick fires per-N-events rather than
per-wall-ms, so game-time dilates. It never stops, which is what it did before. Fidelity
needs a wall clock, and reading one costs a fork per tick on the shells this targets.
A plain VT reports no key release. Hold a key and the tty just repeats the same event at
the OS autorepeat rate, so "is A down?" has no direct answer — an app that wants one infers
it from recency, and every real-time app has to. examples/game.sh already did, by hand, as
MOVE_TTL_US.

The inference has one number in it and that number has to land between two timescales the
app does not choose: above the ~33ms autorepeat interval, or a held key looks released in
the gaps between repeats; below the ~500ms initial repeat delay, or a single tap is still
"down" when the first repeat lands and a tap becomes a hold. Miss on either side and it is
not subtly wrong, it is the wrong feature.

So the framework keeps the window. tuish_key_track declares the keys, tuish_key_down asks,
tuish_key_ttl sets the window. Each matching event refills, each idle tick drains it by
TUISH_TICK_US — which is the other half of why this cannot live in an app: a hosted child
is ticked at its own negotiated rate, and it cannot learn that rate without asking whether
it is hosted, which is a smell. Driving the decay from the parse means a child gets it at
its own rate for free.

TUISH_KEY_REPEAT comes with it, and it is the part that makes the API usable rather than
merely present. It is the press/repeat split kitty reports natively and VT does not,
synthesized from the window already being kept: the slot was empty, so this is a press; the
slot was open, so the key never came up. Without it an app cannot edge-trigger, because the
stamp deliberately lands BEFORE dispatch — a handler must be able to ask about the key that
just arrived — so by the time a binding runs, tuish_key_down says "down" either way.

Kitty sharpens this and is never required: under tuish_detailed_on a -rel empties the window
at once instead of waiting it out. That path also routes around hid.sh spelling a press
`char a` but its repeat `a-rep`, which would otherwise make a physically-held key read as up
the moment any app enabled detailed mode.

The set is one string in one context field, not a table of per-context variables like the
bind table: that table costs tuish_ctx_destroy a hardcoded block reaching into keybind.sh's
internals to unset by name, and a second one is not worth an eval per key per tick to
decrement four integers. Both hot-path hooks are guarded by one comparison, and the bench
says an app that tracks nothing pays nothing.
The game carried its own "is a movement key still held" window, because a plain VT never
says so: pressing jump stops the movement key's repeats, and there is no release event to
miss, so a running jump had to be told from a standing one by recency alone. That window is
now the library's (tuish_key_track / tuish_key_down / tuish_key_ttl), and the demo just
asks.

The value and the reasoning are unchanged — 0.15s, above the autorepeat gap and below the
initial repeat delay — but the decay was the part the game could no longer do for itself.
It aged the window by TICK_DT on its own idle tick, which is right exactly while it owns
the clock; hosted, its tick is negotiated with the host and divided down per child, and the
number it would have to age against is one it does not get to know. Driving the decay from
the parse means a hosted game gets it at its own rate without asking whether it is hosted.

Declaring the set is also how it clears, so the respawn paths re-declare where they used to
zero the counter — a stale hold must not survive a death for the next jump to carry.

Movement is deliberately untouched: _walk still steps a tile per accepted repeat, and
_step_acc still throttles the held run to RUN_SPEED. The dash this branch set out to fix
was never a movement bug — the clock was stopping under a held key, and the tick guarantee
fixed it with this file byte-identical. Rewriting the cadence on top of that would be
changing a feel that is already right.

Verified in a terminal, both directions: a standing jump goes straight up, a running jump
carries.
Three things the code now does that nobody could have guessed from the docs, and one of
them is the constraint this whole branch came out of.

tui.md: an idle event is `read -t<interval>` timing out. Nothing schedules it. So while a
key is held the tick fires only if the interval is SHORTER than the terminal's autorepeat
interval — above it the read never times out and a real-time app's clock stops for the
whole hold. The margin is thin (20ms against ~33ms) and a common `xset r rate` erases it.
The library now refuses to let the clock stop outright, but that is a floor and not a
substitute for picking a sane interval, so both halves are written down. Also retires the
claim that a demo plays the same at any TUISH_IDLE_TIMEOUT: true for anything the tick
integrates, false for anything paced by a held key.

event.md: the rAF deferral is bounded. "Input is pending" is a zero-timeout peek — it says
we are behind, not that a burst is coming — and deferring on it forever withholds the
screen until the key is released. The budget's floor gets said out loud too, because it is
the counter-intuitive end: too small and the queue outgrows the drain.

hid.md: the held-key API, and the three timescales it lives between — above the autorepeat
gap, below the initial repeat delay. Miss either side and it is not subtly wrong, it is the
wrong feature, so the numbers are in a table rather than in someone's head. Kitty is
documented as what it is: an accuracy upgrade to the same calls, never a requirement.

The platformer's header loses its own version of the same lie — it claimed the throttle
kept the idle tick "never starved", which is exactly the assumption that broke.
It can, and the table saying otherwise cost real time: chasing a report of a held key
running slow in the browser, "busybox sh = second resolution, 1s minimum" made a 1s idle
timeout the obvious suspect. It was never there. busybox ash reads fractional timeouts
whenever it is built with FEATURE_SH_READ_FRAC, which is usual, and probes `sub` — natively
and in the wasm build the browser runs.

The table was also the wrong shape for the question. Resolution is PROBED, not implied by
the shell's name: _tuish_init_timing runs `read -t0.01` and believes the answer, so the row
that matters is "a shell whose read -t rejects a fraction", not a list of names that drifts
out of date the moment a build flag changes. Say that, and point at TUISH_TIMING.

While here: note that an idle tick is that read TIMING OUT, and link the constraint that
falls out of it — an app whose interval exceeds the terminal's autorepeat interval gets no
idle at all while a key is held. That is the thing the table sent me looking away from.
@alganet
alganet merged commit 25fe99e into main Jul 17, 2026
11 checks passed
@alganet
alganet deleted the coop branch July 17, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant