SyncTERM embeds Wren in three isolated scripting hosts. A persistent trusted VM owns the main menu, a second persistent VM owns the file picker, and every connection gets a fresh restricted VM which is torn down at disconnect. Connected scripts hook into keyboard, mouse, inbound bytes, outbound bytes, status text, and a periodic timer; menu scripts implement the dialing directory and settings UI; and picker scripts implement the local-file consent interface. Each host exposes a different set of capabilities.

This document is the reference for that scripting layer: how scripts are discovered, how the embedded scripts can be overridden, and the complete add-on object model.

Why Wren

Wren is a small, class-based, dynamically typed scripting language. Three properties make it a good fit for SyncTERM:

  • It compiles to bytecode in-process; no separate toolchain or external interpreter is required.

  • The VM is a few thousand lines of plain C with no external dependencies, vendored under src/syncterm/wren/.

  • The host program controls every binding the script can reach. No filesystem, networking, or process primitives leak in by default.

The full upstream language reference lives at https://wren.io/. This manual covers only the SyncTERM additions.

Quick Start

The minimal "hello world" hook prints a message to the SyncTERM Wren console (Ctrl+`) the first time you press F1 while connected:

import "syncterm" for Hook, Key

Hook.onKey { |k|
  if (k == Key.f1) {
    System.print("hello from wren!")
    return true        // consume the keystroke
  }
  return false         // pass through to SyncTERM
}

Save this as hello.wren in the SyncTERM scripts directory (see Script Loading), connect to any BBS, and press F1. Open the console with Ctrl+` to see the output.

Wren Language Reference

A compact reference for the Wren syntax SyncTERM scripts use. Full upstream documentation at https://wren.io/. Key facts up front: Wren is single-threaded and cooperative (concurrency via fibers, no threads); whitespace-significant (newline terminates statements; no ; token); class-based with single inheritance; dynamically typed.

Comments

// Line comment.
/* Block comment, /* nested */ blocks ok. */

Literals

true false

Booleans.

null

The single null value.

123 0xFF 1.5e3

Numbers. All numeric values are 64-bit floats; 0x for hex; underscores 1_000_000 for digit grouping.

"hello"

String literal. Escapes: \\ \" \% \0 \a \b \e \f \n \r \t \v \xNN \uNNNN \UNNNNNNNN.

"got %(x)"

String interpolation: %(expr) evaluates expr and inserts its toString. A bare % not followed by ( is a lex error — see Strings and %.

[1, 2, 3]

List literal.

{"a": 1, "b": 2}

Map literal.

1..5

Half-open Range (1, 2, 3, 4 — excludes 5).

1...5

Closed Range (1, 2, 3, 4, 5 — includes 5).

Statement Termination

Newlines separate statements. There is no ; token at all. Two statements on one line is a compile error. This trips up developers coming from C / JS / Python regularly.

var x = 1                  // ok
var x = 1 ; var y = 2      // ERROR: Invalid character ';'

Two consequences of the same rule:

  • Ternaries can’t span lines. cond ? a : b must be on one line — the then-branch terminates at a newline before the :.

  • else after a newline-ended if body is a syntax error. if (c) body whose body sits on the same line as the if ends at the newline; an else on the next line is orphaned. Either brace each branch (so else follows }) or put the whole chain on one line.

if (a == "x") foo() else bar()      // single-line, ok
if (a == "x") {                     // braced, ok
  foo()
} else {
  bar()
}
if (a == "x") foo()                 // ERROR: 'else' is orphaned
else bar()

Strings and %

The Wren lexer treats % inside a "…​" string as the start of an interpolation %(expr). A % not followed by ( is a lex error — not a literal %. To put a literal percent sign in a string, escape it as \%:

"100%"          // ERROR: Expect '(' after '%'.
"100\%"         // ok — four bytes: 1, 0, 0, %
"got %(x)"      // ok — interpolation, inserts x.toString

Every literal % needs a leading \. The pair \% is its own escape — \\% does not work, because \\ consumes the slash on its own and leaves a bare %; spell that as \\\%.

Variables

var x = 1                  // declare and initialize
x = 2                      // reassign

Block-scoped (everything between { and }). Shadowing in inner scopes is allowed. Top-level var at module scope is a module-level binding (importable from other modules). Declaration is required before use; there is no auto-vivification.

Operators

Arithmetic: + - * / % (modulo follows the dividend’s sign), unary - and +. Comparison: < ⇐ > >= == !=. Logical: && || ! — short-circuit, return one of their operands (not coerced to Bool). Bitwise: & | ^ << >> ~ — operate on integers (Wren truncates to 32-bit for the bitwise op). Range: .. and .... Conditional: cond ? a : b (one line). Type test: obj is Class.

The .. / ... operators bind tighter than method calls, so write (0...list.count) not 0...list.count when chaining.

Control Flow

if (cond) {
  ...
} else if (cond2) {
  ...
} else {
  ...
}

while (cond) {
  ...
}

for (item in iterable) {
  ...
}

break        // exit innermost loop
continue     // next iteration
return v     // return from method/function

for (x in seq) {} works on anything implementing iterate(prev) and iteratorValue(iter) — Lists, Maps (yields keys), Strings (yields codepoint substrings), Ranges, and your own classes.

Functions and Closures

Wren has no top-level function keyword. Functions are closures created with Fn.new:

var add = Fn.new {|a, b| a + b }
var n   = add.call(3, 4)            // 7

var greet = Fn.new {
  System.print("hi")
}
greet.call()

Block syntax { …​ } after a method name passes a closure as the last argument:

list.map {|x| x * 2 }        // map(_) takes a Fn

Single-line { expr } returns expr implicitly; multi-line bodies return null unless an explicit return is hit. This applies uniformly to Fn.new, Fiber.new, getters, methods, and operator overloads. The inverse is also true: { return expr } on a single line is a compile error — single-line bodies are expression-mode, and return is a statement.

foo() { 42 }                 // ok — implicit return
foo() {                      // multi-line — last expression NOT returned
  var x = 1
  x + 1                      // discarded; foo() returns null
}
foo() {                      // ok — explicit return
  var x = 1
  return x + 1
}
foo() { return 42 }          // ERROR: single-line, return not allowed

Classes

Single inheritance, no abstract classes, no interfaces. All fields are private to the declaring class (see "Field scope" below).

class Animal {
  construct new(name) {
    _name = name
  }

  name { _name }                          // getter
  name=(s) { _name = s }                  // setter

  speak() {                               // method
    System.print("%(_name) makes a sound")
  }

  static kingdom { "Animalia" }           // static getter
  static spawn(n)  { Animal.new(n) }      // static method

  // Operators: + - * / % - (prefix) ! < > <= >= == [_] [_]=(_)
  +(other) { _name + other.name }
}

class Dog is Animal {
  construct new(name, breed) {
    super(name)                           // call super constructor
    _breed = breed
  }

  speak() {                               // override
    System.print("%(name) barks")
  }

  describe() {
    super.speak()                         // call super method
    System.print("It's a %(_breed)")
  }
}

Field scope

Field references (_name, __static) resolve against the class currently being compiled, not the inheritance chain. A subclass' _x is a brand-new slot, not the parent’s _x. Cross class boundaries via getters/setters:

class Widget {
  construct new() { _surface = null }
  surface { _surface }                    // expose to subclasses
}
class Pane is Widget {
  paint() {
    var s = surface                       // ok — uses getter
    var t = _surface                      // BUG: brand-new field, null
  }
}

Symptom of getting it wrong: Null does not implement 'X(,)' errors from subclass methods reading "the parent’s" field.

Naming

  • _name — instance field. Only legal inside a class body; the parser rejects it elsewhere.

  • __name — static field. Same scoping rule.

  • name_ (trailing underscore) — convention for "class-private" methods. Not enforced by the language; a strong project hint.

Foreign methods and classes

foreign declarations bind to host C code:

class Codepage {
  foreign static encodes_(s)              // host-implemented static
  foreign instance_method(arg)            // host-implemented instance
}

foreign class Cell {                      // host owns instance allocation
  foreign ch
  foreign ch=(s)
}

A class needs foreign class only when the host allocates instance data; a plain class with foreign static methods is fine for namespace-style bindings (see Codepage, Hook).

Type checks

obj is Class returns true if Class is in the object’s class chain. Compiles to obj.is(Class). You can override is, but you cannot delegate to the default via super.is(c)is is a reserved keyword and super. requires an identifier after it. Override only if the foreign genuinely implements every method of the claimed class.

Fibers

Fibers are first-class coroutines. Wren is single-threaded and cooperative; concurrency comes from yielding fibers, not threads. There is no await, no Promise, no scheduler — the fiber handle IS the resumption token.

var f = Fiber.new {
  System.print("a")
  Fiber.yield()                  // suspend; control returns to .call() caller
  System.print("b")
}
f.call()                          // prints "a", returns when fiber yields
f.call()                          // prints "b", fiber finishes

// .yield(v) returns v from the .call() that resumed the fiber:
var g = Fiber.new {
  while (true) {
    var x = Fiber.yield(42)       // yield 42, get next call's arg as x
    System.print(x)
  }
}
g.call()                          // returns 42
g.call("hi")                      // prints "hi", returns 42
g.call("yo")                      // prints "yo", returns 42

// .try() catches abort:
var h = Fiber.new { Fiber.abort("boom") }
var err = h.try()                 // err == "boom"; h.error == "boom"

Fiber.yield(v) transfers to the immediate .call() caller — a child fiber yielding inside a hook body returns control to the hook body, NOT up to the dispatcher. This is what makes Fiber.new { …​ }.call() from inside a hook safe.

For host-driven async: a foreign method captures Fiber.current from a slot and returns; the host arranges to call .call(_) on the captured handle later. See Modal Input for the canonical example.

Modules and Imports

import "module"                   // load module, no symbols imported
import "module" for Name          // import a single symbol
import "module" for A, B, C       // multiple
import "module" for Name as Alias // rename on import

Module names are strings; the host resolves them. In SyncTERM, modules are looked up in (1) embedded scripts (compiled in), (2) the user script directory. A user file myhelper.wren in the script dir is importable as import "myhelper".

Common Pitfalls

A checklist of mistakes that catch every new Wren author at least once. Most are consequences of rules above; this section gives them a single place to look up.

  • Semicolons. Wren has no ;. Every separator is a newline.

  • Multi-line bodies don’t auto-return. Use explicit return.

  • Single-line { return x } is a syntax error. Drop return.

  • Ternaries on one line only. Split via if/else or pre-compute.

  • else must follow } on the same line, or chain on one line.

  • Every literal % in a string needs \%. Bare % starts interpolation. See Strings and %.

  • Subclass _field is NOT the parent’s. Use getters/setters.

  • String.count is codepoints; s[i] and s[a...b] are bytes. Mixing them silently truncates UTF-8 strings. For byte iteration use s.bytes.count.

  • String < > >= are not defined. list.sort() on strings aborts; pass an explicit byte-wise comparator.

  • Wren modulo follows the dividend’s sign. -1 % 5 is -1, not 4. For positive-modulo, write ((x % n) + n) % n.

  • foreign class is for instance allocation only. Use plain class with foreign static for namespace bindings.

  • No async / await / scheduler. Use fibers and Fiber.yield.

Wren Standard Library

The built-in classes Wren provides without any host bindings. These are always available, no import needed. Many are mixin-aware via the Sequence protocol, so iteration and transformation methods work on Lists, Maps, Ranges, Strings, and your own classes that implement iterate / iteratorValue.

System

Static-only namespace for I/O and timing.

System.print()

Print a blank line.

System.print(value)

Print value.toString followed by newline.

System.printAll(seq)

Print each item in seq, one per line.

System.write(value)

Print value.toString, no newline.

System.writeAll(seq)

Like printAll without newlines.

System.clock

Wall-clock time in seconds since process start, as Num.

Note
In SyncTERM, System.print / System.write are routed to the Wren console log buffer (visible via the Wren Console pane), not to the process’s stdout. Use Host.print(s) when you need output to reach the launching shell.

Object

Root of every class hierarchy. Every value responds to:

obj == other

Identity by default; classes can override.

obj != other

Negation of ==.

obj.hash

Hash code, integer.

obj.is(class)

Type check; same as obj is class.

obj.toString

Default returns "instance of <Class>". Override for nicer output.

obj.type

Returns the object’s class.

!obj

Boolean negation. Falsy values are false and null only — every other value (including 0 and "") is truthy.

Class

The class of all classes. Useful properties:

Cls.name

String name of the class.

Cls.supertype

Parent class, or Object for Object itself.

Cls.toString

The class name.

Bool

true and false. Has toString (returns "true" / "false") and the standard ! && || operators (the latter two short- circuit and return one of the operands, not a coerced Bool).

Null

The single value null. !null is true; everything else (null.toString, null == null) does what you’d expect.

Num

All numeric values are 64-bit floats. No separate integer type; the language calls something an "integer" when it has no fractional part. Mathematical method-style helpers:

Num.pi

π.

Num.infinity

+∞.

Num.largest, Num.smallest

Max / min finite double.

Num.maxSafeInteger, Num.minSafeInteger

Range of exact-integer doubles (±2⁵³−1).

n.abs

Absolute value.

n.ceil, n.floor, n.round, n.truncate

Round-modes.

n.sqrt

Square root.

n.sin, n.cos, n.tan

Trig (radians).

n.asin, n.acos, n.atan

Inverse trig.

n.atan(x)

atan2(self, x).

n.log, n.log2, n.exp

Natural log, log base 2, eˣ.

n.pow(p)

nᵖ.

n.min(other), n.max(other)

Pairwise min / max.

n.clamp(lo, hi)

Clamp into [lo, hi].

n.fraction

Fractional part.

n.sign

-1 / 0 / +1.

n.isInteger, n.isInfinity, n.isNan

Predicates.

n.toString

Decimal string.

n & m, n | m, n ^ m, ~n

Bitwise (truncated to 32-bit).

n << k, n >> k

Bit shifts (also 32-bit).

String

Immutable Unicode string, stored as UTF-8. s.count is codepoint count; s[i] and s[a...b] are byte-indexed. This pair of facts is the single most insidious Wren string trap — see Common Pitfalls. For byte work, use s.bytes.

s.count

Codepoint count.

s.isEmpty

True iff count == 0.

s.bytes

A Sequence view yielding each byte as a Num.

s.codePoints

A Sequence view yielding each codepoint as a Num.

s[i]

Codepoint substring starting at byte index i. If i is mid-codepoint, returns the raw byte.

s[a..b], s[a...b]

Byte-ranged substring. Negative indices count from the end.

s + other

Concatenation.

s * n

Repeat n times.

s.indexOf(needle)

Byte index of first match, or -1.

s.indexOf(needle, start)

Byte index of match at-or-after start.

s.contains(needle)

Substring presence.

s.startsWith(prefix)

Prefix check.

s.endsWith(suffix)

Suffix check.

s.replace(old, new)

All non-overlapping occurrences.

s.split(separator)

List of substrings, separator removed.

s.trim(), s.trimStart(), s.trimEnd()

Strip whitespace.

s.trim(chars), s.trimStart(chars), s.trimEnd(chars)

Strip any chars in the supplied string.

s.iterate(prev), s.iteratorValue(iter)

Sequence protocol: yields one codepoint substring per step.

s.toString

The string itself.

String.fromByte(n)

Single-byte string.

String.fromCodePoint(cp)

Encode a codepoint as UTF-8 string.

List

Mutable, ordered, dynamic-length, heterogeneous.

List.new()

Empty list.

List.filled(size, value)

Pre-fill size copies of value.

[a, b, c]

Literal.

list.count

Length.

list.isEmpty

True iff empty.

list[i], list[i] = v

Access / replace; negatives count from the end.

list[a..b], list[a...b]

Slice (returns a new List).

list.add(v)

Append.

list.addAll(seq)

Append every item of seq.

list.insert(i, v)

Insert v at index i.

list.remove(v)

Remove first occurrence; returns true if removed.

list.removeAt(i)

Remove and return element at i.

list.clear()

Empty the list.

list.indexOf(v)

Index of first match, or -1.

list.contains(v)

Membership.

list.sort()

In-place sort with <. Aborts on Strings (no < defined) — pass a comparator.

list.sort {|a, b| a < b }

In-place sort with a custom comparator.

list.swap(i, j)

In-place swap.

list.iterate(prev), list.iteratorValue(iter)

Sequence protocol.

list.toString

"[a, b, c]".

Map

Mutable hash map. Keys may be any hashable value (Num, String, Bool, Null, Range, or Class — not List, Map, or arbitrary instance). Iteration yields keys.

Map.new()

Empty map.

{"a": 1, "b": 2}

Literal.

m.count

Number of pairs.

m.isEmpty

Empty?

m[k], m[k] = v

Access / set. Missing key returns null.

m.containsKey(k)

Distinguishes "missing" from "value is null".

m.remove(k)

Remove and return the old value (or null).

m.clear()

Empty the map.

m.keys, m.values

Sequence views (lazy).

m.iterate(prev), m.iteratorValue(iter)

Sequence protocol; iterating yields keys.

m.toString

"{a: 1, b: 2}".

Range

Created via the .. (half-open) and ... (closed) operators on Nums. Lazy — values are produced on iteration.

r.from

Lower bound.

r.to

Upper bound.

r.min, r.max

Bounds normalised regardless of direction.

r.isInclusive

True for ..., false for ...

r.count

Number of values.

for (i in r) {}

Iterate.

Reverse ranges are valid: 5..0 walks 5, 4, 3, 2, 1.

Sequence (mixin)

The base "iterable" protocol. Sequence itself isn’t usually instantiated; List, Map, Range, String, and your own classes that implement iterate(prev) and iteratorValue(iter) inherit its methods. Most are lazy where it makes sense.

seq.all { fn }

True iff fn(item) is truthy for every item.

seq.any { fn }

True iff any item passes.

seq.contains(value)

Membership via ==.

seq.count

Eager count.

seq.count { fn }

Items where fn(item) is truthy.

seq.each { fn }

Apply fn for side-effects.

seq.isEmpty

True iff count == 0.

seq.join()

Concatenate all items' toString.

seq.join(sep)

…with sep between consecutive items.

seq.map { fn }

Lazy mapped sequence.

seq.where { fn }

Lazy filtered sequence.

seq.skip(n), seq.take(n)

Lazy.

seq.reduce { |a, b| …​ }

Fold from first item.

seq.reduce(seed) { |a, b| }

Fold from seed.

seq.toList

Materialise into a List.

Fiber

First-class coroutines.

Fiber.new { …​ }

Create a paused fiber whose body is the closure.

Fiber.current

The fiber currently running.

f.call(), f.call(value)

Resume f; the value is the return of the most recent Fiber.yield inside f.

f.transfer(), f.transfer(value)

Like call, but does not record this fiber as the resumer; cannot be returned to via Fiber.yield.

f.transferError(err)

Resume f with err raised inside it.

f.try(), f.try(value)

Like call, but if f aborts the abort is caught and f.error is set.

f.isDone

True after the body returns or aborts.

f.error

The abort message if try caught one, else null.

Fiber.yield()

Suspend; control returns to the most recent .call() caller.

Fiber.yield(value)

…passing value as the return of that .call().

Fiber.abort(err)

Raise err in the current fiber. Caller’s .try() catches it.

Fn

A callable closure. Fn.new { …​ } is the only constructor.

Fn.new { …​ }

Build from a closure literal.

f.call()

Invoke with no args.

f.call(a, b, …​)

Invoke with args (up to 16).

f.arity

Declared parameter count.

Script Loading

Script Directory

User scripts live in a per-platform directory. SyncTERM creates the directory on first launch if it doesn’t already exist.

Platform Scripts directory

Linux / *BSD

$XDG_DATA_HOME/syncterm/scripts/
(default ~/.local/share/syncterm/scripts/)

macOS

~/Library/Application Support/SyncTERM/scripts/

Windows

%APPDATA%\SyncTERM\scripts\

Haiku

~/config/settings/SyncTERM/scripts/

The directory has two roles, distinguished by where files live within it:

Path Role

scripts/<name>.wren

Pure library module. Loaded only when something imports it via import "<name>". Has no auto-run side effects. Where you put reusable code that other scripts pull in.

scripts/auto/<event>/<name>.wren

Auto-loaded entry script. Runs at the moment the framework fires <event>. menu runs once in the persistent main-menu VM; picker runs once in the persistent file-picker VM; and connected runs once in each connected-session VM.

Files anywhere else under scripts/ (e.g. user-organised subdirectories of pure library code) are reached only via import — the framework owns scripts/auto/ and ignores everything else for auto-load purposes.

Module Names

Each script becomes its own Wren module, named after the file basename without the .wren extension and without any directory prefix. myscript.wren becomes module myscript, whether it’s at scripts/myscript.wren or scripts/auto/connected/myscript.wren. Module names are how scripts cross-reference each other:

import "myscript" for SomeClass

Module names are also how the embedded-script override mechanism works (see below).

Embedded Scripts

SyncTERM ships with a small set of scripts compiled into the binary. They are not stored as files; the build system runs wren_embed_gen at compile time, infers each script’s role from its source path (library vs auto-load, and which event for auto-load), and folds the sources into a C string table linked alongside wren_host.c.

The currently embedded scripts are:

Module Role Purpose

syncterm

library

Foundational module: foreign-class declarations and Wren-side helpers (REPL, Key, Color, Codepage, …). Loads on first import. The menu VM binds only its trusted UI subset; calling any other declared capability aborts the calling fiber.

syncterm_menu

library / menu only

Trusted main-menu data model: transactional BBS-list loading and mutable BBS records, program settings, web lists, and custom-font configuration. The connected loader rejects this module.

syncterm_picker

library / picker only

Request-scoped file-picker interface. It exposes directory listings, path resolution, metadata, and completion methods, but no file-open capability. Menu and connected scripts cannot import it.

picker_bootstrap

reserved library / picker only

Host-controlled picker entry and recovery surface. SyncTERM always interprets its embedded copy before loading picker overrides.

file_picker

auto / picker

Split-pane file and directory browser. It implements single-file, directory, save, and cross-directory multi-file selection and exports FilePicker.run(request) as the picker host entry point.

console

auto / connected

Connected-session Ctrl+` hook for the shared wren_console implementation.

wren_console

library

Wren REPL UI shared by the connected and menu VMs. Each invocation reads the log belonging to its current VM.

connected

auto / connected

Alt+L send-login handler. Sends username, password, and sysop password from the directory entry, with the right send-order rules per connection type.

keys_default

auto / connected

Default Hook.onKey bindings corresponding to the in-terminal hot-key commands in term.c’s input loop: Alt+X / Alt+H and — text-mode terminals only — Ctrl+Q route to `DisconnectFlow.run; Alt+O toggles mouse-event reporting; Alt+Up / Alt+Down walk the network throttle rate; Shift+Insert pastes from the clipboard; Alt+B opens the scrollback viewer; Alt+C delegates to CaptureMenu.run; Alt+M delegates to MusicMenu.run.

disconnect_flow

auto / connected

DisconnectFlow.run(exitApp) — modal "Disconnect…​ Are you sure?" Confirm popup shared by the Alt+X / Alt+H / Ctrl+Q hooks in keys_default. On Yes calls Conn.endSession(exitApp). Imported by keys_default; not used by online_menu (the menu selection is itself the confirmation).

capture_menu

auto / connected

CaptureMenu.run() — Wren implementation of the capture_control contract (capture type / save path / pause / resume / close). Driven by Alt+C in keys_default and the "Capture Control" entry in online_menu.

music_menu

auto / connected

MusicMenu.run() — Wren implementation of the music_control contract (ANSI music mode picker). Driven by Alt+M in keys_default and the "ANSI Music Control" entry in online_menu.

online_menu

auto / connected

OnlineMenu.run() — the SyncTERM "Online Menu" reached via Alt+Z, plus Ctrl+S on text-mode terminals. Lists the in-terminal control commands and dispatches the selection back into the primitives that drive the individual hot-keys (Conn.scrollback, Conn.upload, Conn.download, CaptureMenu.run, MusicMenu.run, …​). The Output Rate and Log Level entries chain into a sub-list before closing. Disconnect / Exit selections call Conn.endSession directly — no Confirm popup, since selecting the entry IS the confirmation.

status_default

auto / connected

Default Status.callable — paints the terminal’s bottom row. See Status for the API and override pattern.

main_menu

auto / menu

The persistent Wren main-menu controller. It loads the menu BBS model, prompts for an encrypted-directory password when necessary, and presents the classic dialing directory: the directory and SyncTERM Settings lists remain visible together, the active list uses the configured Classic Theme palette, and the selected entry’s comment and list commands occupy the bottom two rows. The host calls MainMenu.run(currentName, connected) whenever the application is offline or Alt+E enters the directory over a connected session. The controller binds Ctrl+` to the menu VM’s REPL and displays that VM’s unread-log indicator in the top-right corner. The offline form owns the classic light-shade application background and title/time row; the connected form leaves the saved terminal screen behind its panes.

menu_host_ui

auto / menu

Internal controller used by C connection and startup code for trusted alerts, confirmations, prompts, choices, and progress overlays. An override must preserve the MenuHostUI static method contract described below. Connected scripts cannot import this menu-only module.

The syncterm module is loaded on demand the first time something imports it. Its top level obtains host-created Cache and Download objects where that VM has them. The auto-load embeds run when their owning VM is created: once per application for menu and picker, and once per BBS session for connected.

Overriding Embedded Scripts

A user script whose basename and role match an embedded module overrides the embedded one. The shared REPL implementation can be replaced with ~/.local/share/syncterm/scripts/wren_console.wren; all three VMs import that library when their built-in menu, connected, or picker entry point opens the console. Replacing scripts/auto/connected/console.wren changes only the connected Ctrl+` hook. Replacing scripts/auto/menu/main_menu.wren changes the menu key binding along with the rest of the main-menu controller. Replacing scripts/auto/picker/file_picker.wren changes the picker implementation but not its reserved recovery surface.

The check is an exact match on the bare module name within the same script role. A root console.wren is a library module distinct from the connected auto-load module, while a root wren_console.wren replaces the shared library. Case matters: Wren_Console.wren is a different module from wren_console.wren.

To opt out of an embedded auto-load script entirely, drop a stub override into the matching directory:

// ~/.local/share/syncterm/scripts/auto/connected/connected.wren
// Disable the default Alt+L handler.

The override is loaded but registers no hook, so Alt+L silently does nothing.

Load Order

  1. scripts/auto/<event>/ is globbed for the firing event.

  2. Each embedded script tagged with the firing event runs, unless its module name appears in the user-script set (in which case the user override runs instead).

  3. Each user script in scripts/auto/<event>/ runs as its own filename-derived module.

import statements (whether from a script’s top-level or inside its body) resolve through the lookup chain for the current VM:

  1. scripts/<name>.wren — pure library module.

  2. scripts/auto/<event>/<name>.wren — where <event> is menu, connected, or picker; this catches imports of auto-load modules which have not run yet.

  3. The embedded table — built-in fallback by module name.

The foundational syncterm module lazily loads the first time anything imports it; subsequent imports hit Wren’s module cache.

A script’s top-level code runs once in the VM which owns its event. Top-level code commonly registers hooks or creates the long-lived menu controller; persistent state should be stored on classes (static fields).

The built-in main_menu module is also the host entry-point contract. An override must export class MainMenu with these static methods:

  • prepare() loads the directory, performs any password interaction, and returns a Bool indicating whether the model is ready.

  • run(currentName, connected) presents the directory. currentName is a String or null. When connected is false, return a current menu BBS handle to start that connection or null to exit. Connected invocations are edit-only; their return value is ignored.

  • offerSave(source) receives a menu-owned transient BBS after a command-line connection and returns whether it was saved. The host calls prepare() first, then copies the C record into menu storage; no connection-owned pointer or foreign value is passed into Wren.

When the built-in module is loaded, it paints the main-menu background and title bar immediately. Startup alerts and progress panes therefore appear over initialized menu chrome before MainMenu.run() constructs the interactive directory.

The host validates and copies a record returned by run while the menu VM is still selected, so the Wren foreign object itself never crosses into the connected VM.

The shipped controller binds Ctrl+` to the menu VM’s REPL. A CP437 in the top-right corner reports unread output from that VM: yellow for print output and red for errors. Synchronous key and mouse handlers run through an error boundary; an abort is added to the menu VM’s log, the indicator is refreshed, and the main menu continues running. This boundary does not hide layout or painting failures, which still abort MainMenu.run rather than repeatedly failing during every redraw.

The shipped controller defines the main-menu interaction contract. Tab from the directory enters the selected entry’s comment field, then advances to the simultaneously visible settings pane; Backtab traverses the same path in reverse. Left and Right move directly between the directory and settings. The first completed left click in an inactive pane transfers focus without activating a row; a second click activates the row. A wheel event moves the active list regardless of which pane is under the pointer. A completed left click on the main-screen background transfers focus to the other pane. Enter connects (or edits while connected) from the directory and invokes one settings action from the settings pane. F2/Ctrl-E, Insert, Delete, F5/F6, Ctrl-S, </>, Ctrl-D, and Alt-B invoke their documented commands. F2 does nothing on the blank append row; Ctrl-E invokes the row’s add operation. The comment row is also clickable for direct editing. An override may present the model differently, but it must provide this user-visible behavior.

The comment is edited only in the main screen’s footer. It is not a field in the full directory-entry editor. F1 and Ctrl-Z in the footer display the directory help. Operation-specific alerts and confirmations display the context help for the operation which opened them.

The footer advertises edit, copy, paste, insert, and delete only while the directory pane owns focus. The SyncTERM Settings pane advertises only Help and Exit, matching the operations available from that pane. While a comment is being edited, the footer shows the hints for the pane that opened it.

Lists which support appending provide a selectable blank final row. Activating the blank row or pressing Insert invokes the same add operation. The shipped directory, sort-profile, sort-field, web-list, custom-font, and short-palette editors expose both forms; the palette row is omitted once all sixteen colors are present. + aliases Insert, while - and ASCII DEL alias Delete. Ctrl-Insert aliases F5 Copy, Shift-Insert aliases F6 Paste, and Shift-Delete invokes Cut where that operation is available. Backspace and a right click alias Escape after the focused widget declines the event. Delete and the editor-specific copy, paste, rename, and cut keys are direct list commands.

Adding a directory entry prompts for its name, connection type, and address, then writes it immediately and returns to the directory. A single :port suffix is accepted for network addresses. F2 or Ctrl-E opens the full entry editor. Editing a read-only entry first offers to create a personal entry with the same name. F5/F6 prompts for a new name when pasting a personal entry; pasting a copied read-only entry uses the copied name.

The specialized editors provide direct menu operations. Enter on a sort profile opens its field list, and Enter on a sort field reverses that field; [ and ] move between profiles without returning to the profile list. Enter on a web list edits its URI. Font details are a flat Name plus four cell-size paths, and selecting a path opens the picker directly. The encryption screen has distinct Change Password, algorithm, and Decrypt operations; changing algorithms reuses the password already held by the host. Palette editing exposes separate Red, Green, and Blue fields, uses Tab and Backtab between them, cycles the preview foreground with Up and Down, and uses % to reset all three components to the mode default. Each component is selected when entered, so typing replaces its current value. Enter commits the current component; Tab and Backtab move without accepting uncommitted text.

In safe mode, accepted default-entry and web-list changes update the in-memory model but are not written to configuration files. Encryption conversions are no-ops; Change Password presents its password prompt before returning.

When an action rebuilds a repeating editor’s list, the editor reselects the same row. This applies to entry logging and palettes, sort profiles and fields, program settings and their nested lists, web lists, and font management. Directory editing commands apply only while the directory pane owns focus. Directory, pasted-entry, command-line-save, sort-profile, and web-list name prompts repeat after an invalid or duplicate name. Entry-editor prompts distinguish empty, reserved, and duplicate names. Directory add and command-line-save prompts treat an empty edit as cancellation while still distinguishing reserved and duplicate names; an entry creation failure is reported separately. Sort-profile field navigation returns to the last profile visited. New entries are inserted according to the active sort profile after their connection details are accepted. Quick Connect limits the directory address field to 64 characters. Current Screen Mode and Startup Screen Mode use distinct dialog titles and context help. Directory password and system-password rows are obscured in the editor list, but selecting one uses ordinary text editing so the existing value can be inspected and corrected. Personal-directory encryption prompts are masked. Rate choices select the first supported rate at or above a configured value, or Current when no supported rate is high enough. An OS window-close request sets a sticky process-exit latch. It does not ask the main menu or connected session for confirmation. An App unwinds nested widgets through their normal Escape path one modal frame at a time, allowing each Wren call to return and run its normal cleanup. A widget explicitly marked atExit remains interactive; the command-line directory-entry save workflow uses this for its confirmation, prompts, errors, and editor. Accepted Classic Theme colour changes rebuild the active theme immediately while Classic Theme is selected. A selected file theme is unaffected. Renaming a persisted entry also renames its per-entry cache directory after the directory file is written successfully. Font Management treats an empty name as cancellation when adding a record. Otherwise, it accepts the entered name and the file returned by the picker as the record values. It reports a full custom-font table before prompting for another name. The preformatted File Locations and Build Options viewers use the titles File Locations and Build Options. Their maximum dimensions are 78 by 21 and 60 by 21 cells respectively, capped to preserve the standard modal margins and complete drop shadow. Their Markdown content defines the label emphasis, indentation, and section layout rendered by the shared Help viewer. Palette Insert is exposed only while the palette has room for another color, and Delete is exposed only while a color can be deleted. Sort-profile Paste is exposed only after Copy or Cut has supplied a clipboard profile. Font Details and the outer Font Management list both support Insert, Delete, and the blank add row. Adding a web list skips its initial fetch when no cache path is available but records the configuration; explicit refresh reports the missing cache. Left and Right move directly between the directory and settings panes. Leaving the comment field by mouse, pane navigation, or application exit commits the accepted edit before focus moves. The entry editor reselects its highlighted field after [ and ] move between entries. Program Settings summarizes enabled audio backends, and entry rows distinguish automatic terminal types, unset passwords, and nonzero communication rates. Web-list edits update the in-memory configuration immediately and are written when the Web Lists screen closes. Saving an existing directory entry does not change its Added timestamp. Clearing the Terminal Type field writes the empty override, so a subsequent directory reload uses automatic terminal-type selection. In the palette component editor, % resets all three components of the selected colour from the mode palette without moving focus to another field. Creating or copying a personal entry with the same name as a read-only entry replaces that row immediately. The model stores the read-only entry beneath the personal override. Deleting the override exposes the read-only entry; renaming the personal entry exposes the read-only entry under its own name. Adding an entry may create such an override. Pasting a copied personal entry instead requires a name unique across all currently visible entries.

The inactive directory comment is centered across its line. While the directory pane has focus, its selection supplies the OS window title; while the settings pane has focus, the title is the bare SyncTERM version. Editing a comment leaves the title from the pane where editing began until focus moves again. The active editor is left-aligned within the middle screen width - 4 cells. Its existing value initially appears selected in the text-input lightbar, so typing or deleting replaces the complete comment. Cursor movement or mouse positioning leaves the value unchanged and switches to the normal menu colors for ordinary editing. Leaving the editor immediately centers the inactive row, even when its value was unchanged.

The shipped menu keeps context help beside the Wren screen that owns each control. Directory, sort-profile, entry-editor, settings, web-list, font, and encryption panes assign Markdown helpText to their panes and nested dialogs. F1 therefore resolves to the general screen description from the list and to field-specific help while a choice or prompt is open. Menu overrides should use the normal Help Markdown contract described under Help viewer. Help content must be Markdown; legacy help-buffer markup is unsupported.

The shipped editors distinguish accepting an edit from writing its backing file. A confirmed entry field immediately updates the C-owned BBS, and a confirmed program setting is immediately applied to the running settings. Their files are written when the owning entry or Program Settings editor closes. Font changes stay in the font model until Font Management closes. Sort-profile operations update the working profile model, which is written when Sort Profiles closes; profile cycling from the main directory writes immediately. None of these screens exposes the persistence boundary as a selectable Save or Cancel row.

Escape from a nested prompt or choice cancels only that nested operation. Escape from an owning editor closes it without rolling back accepted edits. An owning menu stays on the modal stack and is rendered behind a nested prompt, picker, viewer, or submenu until that child is dismissed.

VM Lifetimes and Trust Boundaries

SyncTERM deliberately uses three Wren VMs. They do not share Wren objects, handles, modules, globals, hooks, timers, result queues, or foreign objects.

  • The menu VM is created once after conio and configuration are initialized. It owns scripts/auto/menu/, survives connections, is parked while the connected screen is active, and is destroyed at application shutdown. It can edit the BBS list and settings and can request a picker operation.

  • A fresh connected VM is created by wren_host_init(bbs) for each BBS session and freed by wren_host_shutdown() on every exit path. It owns scripts/auto/connected/. Module statics, hooks, timers, claims, and queued results do not survive disconnect.

  • The picker VM is created once after the menu VM, owns scripts/auto/picker/, and remains parked except while a picker request is active. It cannot import syncterm_menu, inspect BBS records, change settings, establish connections, or open files. Its request object exposes only directory listings, path resolution and metadata, and one-shot completion methods.

The remote system is considered hostile. A remotely supplied script may know the active entry’s username, password, and system password; those values are already meaningful to that remote. It must not gain the authority to enumerate or edit other BBS entries, change global settings, start another connection, or mint arbitrary local-file access. Consequently syncterm_menu is not merely omitted from the connected documentation: the connected loader explicitly rejects it, and menu foreign objects cannot cross the VM boundary.

Menu auto-script overrides are trusted application code, not a sandbox for downloaded scripts. In addition to directory and settings mutation, the menu VM can invoke picker-mediated local-file operations, open URLs from the completed session’s scrollback, and update trusted application chrome. Do not install a remotely supplied script under scripts/auto/menu/; remote or otherwise untrusted session automation belongs in the per-connection VM and receives only that VM’s capabilities.

Connected scripts intentionally retain Input.ungetKey, Input.ungetMouse, physical-key synthesis, and the other synthetic input bindings. User scripts need them to automate the current remote session. Isolation is provided by the VM and transition barriers, not by removing those session-local controls.

At every ownership transition (connect, disconnect, entry to or return from the menu through Alt+E, and entry to or return from the picker), the host establishes a new input epoch. It discards pending conio keyboard and mouse pushback and rejects late input-shaped results tagged with an older epoch. Input claims remain parked in the VM that owns them and cannot run while another VM owns the screen. This prevents a connected script from placing synthetic input in ungetkey() / ungetmouse() just before the trusted menu or picker takes control. The same barrier protects the connected VM when control returns from a trusted UI.

The picker VM is the local-file consent broker. Neither its caller nor the picker script receives arbitrary pathname-open authority. A single-file or multi-file completion is converted by C into read-only File handles. A save completion explicitly reports create or overwrite consent; C converts it into a one-shot write-only grant without retesting the pathname. These rights remain attached to the foreign File handle as documented in Write consent. The request object is invalidated as soon as the picker returns, so it cannot retain listing or completion authority between calls.

Picker overrides under scripts/auto/picker/ are trusted local application code, but the picker VM still receives only the capabilities needed by the browser. The host always interprets the embedded picker_bootstrap first. A picker implementation compile or runtime failure therefore enters a recovery screen instead of terminating SyncTERM: Ctrl+` opens the picker VM’s REPL, Escape cancels the request, and Key.quit exits the application. The normal picker shows that VM’s unread or error indicator at the top right and uses the same Ctrl+` binding. Only failure to initialize the VM or reserved bootstrap disables picker operations.

C-owned startup and connection flows also render their dialogs through the persistent menu VM. These calls do not expose a dialog capability to connected scripts. Blocking alerts, confirmations, prompts, and choices save and restore the current screen and establish input barriers both before and after the dialog, so synthetic connected-session input cannot answer a trusted question. Progress overlays deliberately do not clear or claim input: connection setup continues polling conio so the user can abort a stalled operation. Clearing progress restores the exact screen, palette, fonts, video flags, and cursor state saved when the first update was shown.

The embedded menu_host_ui module exports class MenuHostUI with static methods alert(title, message), confirm(title, message), prompt(title, message, initial, maxLen, masked), choice(title, message, options, current), status(title, lines), and statusClear(). It is a host-controller ABI rather than a public menu script API: application C code holds the call handles and invokes it. The progress pane is 74 columns wide when the screen permits, matching the C formatter and the previous UIFC window. The pane itself is centered, but all status rows share one padded left edge so preformatted columns remain aligned between rows and updates. Text wraps within that common content width on narrower screens. Menu auto-script overrides are trusted and may replace its presentation, but must keep those signatures and return Bool, String/null, and numeric choice indexes as appropriate or host dialogs fail closed.

Every reconnect rebuilds the connected VM from scratch. Connected scripts needing cross-session state must persist it through an authorized Cache / Download file or picker token. Menu scripts can keep ordinary Wren state for the application lifetime, but durable changes still require the explicit model save methods.

Console entries, cached entry handles, REPL state, and unread markers belong to one VM. Opening the menu through Alt+E therefore shows the menu VM’s log, not output produced by the parked connected VM, and no console object or handle crosses the trust boundary.

The owner thread is captured at init; dispatchers called from any other thread (background SFTP and SSH writes invoke conn_send from worker threads) short-circuit to pass-through. Scripts run on the foreground thread only.

Importing the API

Shared host bindings live in module syncterm. Every script imports the classes it needs:

import "syncterm" for Screen, Input, Conn, Hook, Key

An import does not grant all methods declared by that module. The menu VM resolves the declarations so shared UI libraries can load, but forbidden methods abort with "this capability is not available in the menu VM" if called. Unknown foreign declarations remain module-load errors. Menu-only data APIs are imported separately:

import "syncterm_menu" for Menu, BBS, Settings, MenuFont, MenuReadStatus, MenuEncryption, MenuFontSlot

Cache is injected into the syncterm module from C as a module-level Directory object — there is no Wren-callable constructor. Import it like any other binding:

import "syncterm" for Cache

Hook Events

Hooks are registered as Wren callables (block, function, or method reference). Each call site walks its hook list in registration order; the first hook returning true consumes the event and stops dispatch. Returning false, returning a non-Bool, or throwing passes through to the next hook and ultimately to SyncTERM’s default handling.

Every registration returns a HookHandle (or null if the per-event limit is hit) — see HookHandle. Save it if the script needs to remove the hook later or read its metrics; toss it if not.

Hooks must run synchronously

Every hook fire is wrapped in a child fiber via Hook.dispatch_. For C1-contract hooks (see Two return contracts) the dispatcher needs the hook’s return value before it can decide what to do next; a hook that yields up to the dispatcher would strand it with no value to act on. C2-contract hooks ignore returns, but yielding still breaks the dispatch chain.

If a hook callback yields its own fiber directly (e.g. fires an async op against Fiber.current and immediately calls Fiber.yield()), the dispatch wrapper detects the yield and logs:

hook handler must not yield directly; wrap parking work in
Fiber.new { ... }.call()

The hook is then treated as if it returned a non-Bool — the input passes through to the next hook and to SyncTERM’s default handling. A Fiber.abort from the hook is caught the same way: logged with a stack trace, treated as passthrough.

A hook that needs to wait on async results has two options that keep the hook itself synchronous. The first wraps the work in a child fiber and `.call()`s it from the hook body:

Hook.onKey { |k|
  if (k == Key.f2) {
    Fiber.new { runModalBrowser() }.call()
    return true
  }
  return false
}

.call() runs the child fiber synchronously until it yields (e.g. on an SFTP op or a Fiber.yield() waiting for a Wake.post). The child’s yield returns to the hook body — the immediate .call() caller — not up to the dispatcher. The hook still returns its Bool; the child resumes later via the framework’s result-queue drain.

The second option is to fire the async op against a Fiber.new {|r| …​ } whose body runs when the result arrives. The hook returns synchronously without ever yielding; the callback fiber is invoked later by the drainer:

Hook.onKey { |k|
  if (k == Key.f2) {
    SFTP.realpath(Fiber.new {|r|
      // handle r …
    }, ".")
    return true
  }
  return false
}

Two return contracts

Every hook callback follows one of exactly two return-value contracts. Knowing which contract a hook uses tells you what (if anything) your return value does.

Contract Behaviour

C1 — replaceable

true consumes the event (no later hook sees it; SyncTERM’s default handling is bypassed). String replaces the event’s data with the bytes of the string where that’s meaningful (currently only onInput byte replacement). Any other return — false, null, a number, an instance, … — is a passthrough: the next hook in the chain sees the unmodified event, and SyncTERM’s default handling proceeds.

C2 — observe-only

The return value is ignored entirely. These hooks fire as a side-effect notification; the event continues regardless. A true or String return is dropped, AND the runtime logs a warning every time it happens — the noise is the diagnostic, intended to make the "I thought I could consume from here" mistake impossible to miss. Return null (or just don’t return) to silence it.

onKey, onPhysicalKey, and onMouse use C1 but have no meaningful interpretation for String returns (a 16-bit key code, a PhysicalKeyEvent, or a MouseEvent isn’t a byte stream), so a String return from those is silently treated as passthrough. Use onInput if you need byte-level replacement.

Method Argument Contract

Hook.onKey { |k| …​ }

k: 16-bit ciolib key code (see Key)

C1

Hook.onKey(key, fn)

Filtered: fn(k) only fires when k == key. C-side filter — no Wren entry on misses.

C1

Hook.onInput { |b| …​ }

b: one byte received from the remote

C1. String replacements are capped at 256 bytes — larger ones log a runtime error and the original byte passes through.

Hook.onInput(byte, fn)

Filtered: fn(b) only fires when b == byte. C-side filter.

C1

Hook.onPhysicalKey { |ev| …​ }

ev: PhysicalKeyEvent instance. Registering this hook enables ciolib physical-key event delivery for the session on builds/backends that support it.

C1

Hook.onPhysicalKey(evdev, fn)

Filtered: fn(ev) only fires when ev.evdev == evdev. C-side filter.

C1

Hook.onMatch(pattern, fn)

fn(m) fires when pattern matches the input stream. See "Streaming regex hooks" below.

C2. An "honor true-means-drop" semantics would require holding every input byte until every potential pattern resolves — not feasible for a streaming terminal. Use Hook.onInput for byte-granular drops.

Hook.onMatchClean(pattern, fn)

Same as onMatch, but bytes are pre-filtered through SyncTERM’s shared ANSI parser so colour codes, cursor moves, and DCS / OSC strings never split a literal pattern. A pattern like "Welcome" matches even when the BBS sends "We" + ESC[1;33m + "lcome".

C2

Hook.onMouse { |ev| …​ }

ev: MouseEvent instance

C1

Hook.onMouse(event, fn)

Filtered: fn(ev) only fires when ev.event == event. C-side filter.

C1

Hook.every(ms, fn)

ms: milliseconds; fn: callable

C2

Hook.onShellClose(fn)

Fires once when the SSH shell channel closes but the session is still alive (e.g. SFTP transfers may still be in flight). Typical use: pop a status / queue UI so the user can watch transfers finish, or signal a worker to wind down. See CTerm.sftpActive for the corresponding "keep the session up" flag.

C2

Hook.onDisconnect(fn)

Fires at the end of the disconnect path, after the main loop has fully exited but before the BBS / Wren VM tear down. SFTP is no longer available at this point (sftpc_finish has already run); use this for final local-state flushes (queue persistence, log close, etc.).

C2

Filtered variants share the same per-event registration array as the unfiltered forms, so dispatch order = registration order regardless of which form was used.

Hook.onInput (and Hook.onMatch) fires on every byte off the wire, before RIPscrip parsing, ZMODEM/OOII detection, or the cterm emulator. Scripts see the raw stream and can drop bytes that would otherwise be consumed by those layers, or expand a single byte into multiple bytes by returning a String — the canonical example is LF→CRLF normalization for hosts that send only LF. Bytes are dispatched in bulk right after conn_recv_upto, so there is no speed-emulation gating — parse_rip already eats RIP escapes in bulk regardless of the emulated bps rate, so gating only the Wren hook would be inconsistent.

Hooks run in registration order; the first one that returns Bool true (drop) or a String (replace) wins, and later hooks don’t see that byte. When a replacement won’t fit in the post-filter buffer, the filter pauses on that input byte; the unprocessed wire-side tail stays parked until the next recv_bytes() call drains something out and frees room for it.

Hook.every fires from the main-loop deadline check just before the sleep call. If the loop stalls long enough that more than one interval has elapsed, the deadline jumps forward to "now" rather than firing repeatedly to catch up.

The status bar is a separate dispatch path — see Status. A single render callable (rather than a hook chain) builds the row into a Surface the host hands it.

Streaming regex hooks

Hook.onMatch(pattern, fn) registers a regex against the inbound byte stream. Each input byte is fed to a streaming Pike VM (Russ Cox’s NFA simulation, vendored under re1/); when a match completes, fn is called with a Wren List:

Hook.onMatch("login:") { |m|
  Conn.send("user\r")
  return false
}

Hook.onMatch("user (joe|jane|bob)") { |m|
  // m[0] = "user joe", m[1] = "joe"
  System.print("hi %(m[1])")
  return false
}

m[0] is the matched substring; m[1..] are the user-pattern’s capture groups in registration order. Both onMatch and onMatchClean are passthrough-only: the callback’s return value is ignored, and the matched text always reaches the terminal. Matching completes only after preceding bytes have passed through cterm, so these hooks cannot drop a complete matched span. Use the byte-granular Hook.onInput hook when input must be dropped before reaching cterm.

Hook.onMatchClean(pattern, fn) is the escape-aware variant — the byte stream feeding the regex VM is pre-filtered through SyncTERM’s shared ANSI parser (ansi_filter in ansi_filter.[ch], the same state machine ripper.c uses to find ANSI envelopes inside RIP). The filter strips ESC, CSI sequences (ESC [ …​ <final>), DCS / OSC / PM / APC strings (ESC P|]|^|_ …​ ESC \), and SOS-style strings (ESC X …​ ESC \). The cleaned bytes reach the regex VM in the same per-byte-stream form as onMatch; what the user sees is what the matcher sees.

Grammar

RE1 implements a deliberately minimal regex dialect. These are all of the supported metacharacters:

Construct Meaning

literal byte

matches itself. Backslash has no special meaning — \ matches a literal backslash. To match an operator metacharacter literally, surround it via a non-capturing group with itself escaped is not possible; instead choose patterns that don’t conflict.

.

any byte (including NUL — the streaming VM does not treat NUL as end-of-input)

(…​)

capturing group

(?:…​)

non-capturing group

|

alternation

* + ?

greedy: zero-or-more, one-or-more, optional

*? +? ??

lazy (non-greedy) variants

Notably not supported (will be parsed as literal characters or syntax errors):

  • backslash escapes — \n, \t, \d, \w, \s, \xNN, …​

  • character classes — [abc], [a-z], [^…​]

  • anchors — ^, $, \b, \B

  • backreferences — \1, \2, …​

  • counted repetition — {n}, {n,m}

  • POSIX classes — , …​

  • inline flags — (?i), …​

Patterns are anchored at the current buffer start. "Match anywhere in the stream" is achieved by the dispatcher: when the VM returns IMPOSSIBLE (no thread can ever complete from the current buffer head), the oldest byte is dropped and the survivors are re-fed. In practice this means a pattern like "hello" matches the substring "hello" wherever it appears in the stream.

This trick requires that the pattern can produce IMPOSSIBLE — i.e., the first byte either advances the NFA or kills every thread. Patterns whose leading construct can match without consuming a byte keep threads alive on every input forever, the buffer fills, and matches start being silently dropped. Hook.onMatch therefore rejects patterns whose leading construct is *, +, or ? at registration time with Fiber.abort:

Allowed (1-byte anchor) Rejected (variable-width leading)

hello

.*hello

.bbs

.+bbs

(a|b)c

a*b

(?:foo|bar)baz

(a|b)*c

If you want a quantifier inside the pattern, anchor it with a fixed prefix: bbs (.) ` rather than `(.) bbs.

Match semantics

Pike VM has a leftmost-first-completing tie-breaking rule: as soon as any thread reaches a Match opcode, the match commits and remaining threads in the current step are discarded. In a streaming context this means open-ended quantifiers fire as soon as the shortest acceptable prefix is seen — a* matches the empty string at the first byte, a+ matches the first 'a' and stops.

For greedy-feeling behavior, terminate variable-length sub-patterns with a literal that follows them: user (.) ` (capture text up to a space) instead of bare `user (.). Newlines must be encoded as literal \n bytes in the Wren string (“\n” in the source code, which Wren resolves to a single 0x0A byte).

Limits

  • Buffer is capped at 4 KB per hook. When a partial match grows past the cap, the oldest half is dropped and the VM restarts — patterns that demand more than 4 KB of context will silently miss.

  • Up to 9 capture groups (RE1’s MAXSUB = 20 minus the whole-match pair).

  • Pattern compile errors (bad syntax, internal asserts) surface as Fiber.abort at the registration site, with the RE1 error text attached. The exception trace points at the offending Hook.onMatch call.

HookHandle

Every successful Hook.on* and Hook.every registration returns a HookHandle. The class has no Wren-callable constructor, so a script can’t fabricate one to remove arbitrary hooks — only its own.

import "syncterm" for Hook

var h = Hook.onKey { |k|
  if (k == 0x4200) {        // F8
    System.print("F8 pressed")
    return true
  }
  return false
}

// ...later...
h.remove()                  // tombstone: no further dispatch
Member Meaning

remove()

Tombstone the hook. Future dispatches skip it. Safe to call from inside the hook’s own callback (the host removes by NULL-ing the callable; in-flight dispatch finishes on the still-allocated resources). Returns true on first call, false thereafter.

callCount

Number of times the host has invoked this hook. Counts every wrenCall, regardless of whether the callback returned truthy or errored.

totalRuntime

Cumulative wall-clock seconds spent inside wrenCall for this hook, measured with xp_timer() before and after each invocation.

minRuntime, maxRuntime

Smallest / largest single invocation time in seconds. Both read back as 0 for a never-fired hook; min is seeded on the first call.

HookHandle.remove() is safe from inside the hook’s own callback. The entry’s fn handle is released immediately (dispatchers skip past the now-NULL slot on their next iteration), and the entry is linked onto a cleanup queue. The host drains that queue once per main-loop iteration — outside any dispatcher — at which point the entry is removed from its dispatch array, regex resources (compiled program, match buffer, PikeVM state) are freed, and the entry struct itself is freed once the script has also dropped the HookHandle (Wren’s GC fires the foreign-class finalizer). Metric getters on a removed handle keep working until the script drops the handle.

The conventional Input.next() / Input.next(ms) / Input.poll() primitives all return promptly; while a script is calling them in a loop the doterm() main loop is blocked, and inbound server bytes queue up in the socket buffer until the script returns.

For modal dialogs that should not block the main loop while the script idles between events, use Input.pushClaim(fn). The foreign installs fn as the current claim on a stack of input claims; whenever a key or mouse event arrives, the C-side dispatcher walks the stack head-first (newest first) and invokes the first claim whose owning fiber is still alive. The claim’s return value is a Bool — true consumes the event (no other claim or hook sees it), false passes it through to the next claim down and ultimately to the registered Hook.onKey / Hook.onMouse chain.

import "syncterm" for Input, Key, Wake, Screen, CTerm

var saved = Screen.save()
CTerm.suspended = true
var me = Fiber.current
var claim
claim = Input.pushClaim(Fn.new {|ev|
  // Decide consume / passthrough synchronously here — handlers
  // run from the C dispatch frame and must not yield.  Anything
  // we want to *do* with the event is dispatched by waking the
  // worker fiber via Wake.post; the worker yields/resumes on
  // its own schedule.
  Wake.post(me, ev)
  return true
})
while (true) {
  var ev = Fiber.yield()
  if (ev is KeyEvent && ev.code == Key.escape) break
  Screen.window.print("got: %(ev)\n")
}
claim.pop()
Screen.restore(saved)
CTerm.suspended = false

The claim is a per-fiber slot: pushing twice from the same fiber replaces the existing claim in place (preserving stack position). Pushing from a different fiber adds a new entry on top, which becomes the foreground. Auto-prune at dispatch time drops claims whose owning fiber is isDone (so a script that aborts mid-claim doesn’t leave a dead handler installed). The returned ClaimHandle is the explicit way to remove the claim; pop() is idempotent.

CTerm.suspended is independent of the claim mechanism — any script can set it to claim the screen, and remote bytes pile up in the conn buffer behind it. When the TCP receive window fills, the remote sees its send() calls block or EAGAIN. Clearing the flag releases the backpressure and the buffered bytes drain through cterm normally.

The claim handler runs from the C dispatch frame via the same Hook.dispatch_ wrapper that protects regular hooks (see "Hooks must run synchronously" above) — so a yield inside the handler is caught and treated as not-consumed, and the input falls through to the next claim or hook chain. Heavy work belongs in a separate fiber that the handler wakes via Wake.post.

A hook callback can install its own claim and let it persist past the hook return — perfect for "F2 opens a modal that captures input until Esc":

Hook.onKey(Key.f2) { |k|
  Fiber.new {
    var claim = Input.pushClaim(Fn.new {|ev|
      Wake.post(Fiber.current, ev)
      return true
    })
    while (true) {
      var ev = Fiber.yield()
      if (ev is KeyEvent && ev.code == Key.escape) break
      // ...
    }
    claim.pop()
  }.call()
  return true
}

For more elaborate UIs, the App class (see App) wraps this pattern and provides synchronous claim decisions based on its own state (modal stack, focused widget, mouse hit-test) plus a runChild helper that lets handler code yield safely on async work without breaking the App’s event loop.

Built-in REPL

Ctrl+` opens the immediate-mode REPL for the VM which owns the current screen. The shared UI is implemented in scripts/wren_console.wren and can be overridden with ~/.local/share/syncterm/scripts/wren_console.wren. The connected entry point is scripts/auto/connected/console.wren; the persistent menu binds the same key in scripts/auto/menu/main_menu.wren.

While the connected console is open the doterm() main loop is suspended. Connection bytes accumulate in the socket buffer but aren’t drained until you exit the console. This is acceptable for a development tool; not appropriate for "watch a slow BBS scroll" use cases. The menu already uses a synchronous event loop, so opening its console does not change its event-pumping model. When the menu was entered through Alt+E, the connection is already parked for the entire menu visit.

The REPL evaluates input through REPL.eval(module, src). The input is pre-classified by inspecting its leading non-whitespace token: source that begins with a Wren statement keyword (var, class, import, return, break, continue, if, while, for) is compiled as a statement; everything else is compiled as an expression. Successful expressions print their result quoted with C-style escapes (so "7" the string and 7 the number are visibly distinct); statements print nothing.

Variables and class declarations persist across submissions inside the active module:

> var x = 1 + 2
> x * 10
30
> class Foo { static greet { "hi" } }
> Foo.greet
"hi"

REPL Commands

Command Action

/help, /?

Print commands, editing keys, history navigation, scrollback navigation, and exit keys.

/in <module>

Switch the eval target to <module>. Default is syncterm in the connected VM and main_menu in the menu VM. Use this to inspect or mutate any other loaded module. /in with no argument prints the current target.

/mods

List every Wren module currently loaded into the VM, alphabetically sorted. Includes core (Wren’s built-in classes), every embedded module (syncterm, wren_console, connected or main_menu), every user script loaded from the scripts directory, and any module pulled in via import.

/quit, /q

Close the REPL and return to the connected session or main menu.

Modules can plug in their own /<command> entries via WrenConsole.register(name, help, fn):

import "wren_console" for WrenConsole

WrenConsole.register("greet", "say hello [<name>]") { |args|
  if (args == "") {
    System.print("hello!")
  } else {
    System.print("hello, %(args)!")
  }
}

The handler is called with the raw argument string — everything after the command name and its first separating space, or "" if none. Re-registering an existing name overwrites the previous binding. Names can’t contain spaces (the dispatcher splits on the first one). The dispatch wraps the handler in Fiber.new {}.try() so a runtime abort surfaces as a logged error instead of tearing the console out from under itself. /? lists every registered command with its help text alongside the built-ins.

WrenConsole.unregister(name) drops a registration (idempotent — no-op if not registered); WrenConsole.commands returns the sorted list of currently-registered names for tooling.

REPL Key Bindings

Key Action

Left / Right

Move the cursor within the current input line.

Home / End

Jump to start / end of the current input line.

Backspace

Delete the character before the cursor.

Delete

Delete the character at the cursor.

Up / Down (live mode)

Walk command history. If the line currently has typed text, that text becomes a prefix anchor — only history entries starting with it are visited.

PgUp / PgDn

Page through the scrollback log buffer. PgUp from live mode pins the current top of view; PgDn returns to live once you’ve scrolled past the tail.

Up / Down (scrollback mode)

Single-row scroll. Down past the tail rejoins live mode.

Enter (blank or whitespace-only)

Advance to a fresh prompt row without evaluating.

Ctrl+W

Backward kill-word, ending at the cursor (the tail past the cursor is preserved).

Ctrl+L

Clear the screen and the in-memory log. Returns to live mode.

Middle-click

Paste from system clipboard at the cursor. Multi-line text is split on LF and each line submitted as if Enter were pressed.

Alt+drag

Hand off to the existing select-and-copy gesture.

Ctrl+` / Esc / /quit

Close the REPL.

Object Model

The remainder of this document is a reference for every foreign class exposed in the syncterm module.

A note on toString: every class with an explicit toString method returns it strictly for human-readable debug output — the format that System.print(value) and string interpolation "%(value)" emit when no other rendering is appropriate. The exact string is not part of the API contract; it may change between SyncTERM versions to add or remove fields, reformat, etc. Scripts that need specific data should call the named property accessors (cell.ch, event.code, surface.width) — never parse toString output. Without an explicit toString a foreign would print as something like [instance of Cell], which is uniformly less useful than even a minimal Cell(0x41 attr=0x07); that’s the only motivation for having these methods at all.

Error / ScriptError

Error is the polymorphic base for every recoverable-failure value type the API hands back: SFTPError, FileError, WONError, ConnError, plus any user-defined ScriptError subclass. Lets a script catch any of them with one check:

import "syncterm" for Error

var v = something_that_might_fail()
if (v is Error) {
  System.print("failed: %(v)")        // toString includes the type
  return
}
// proceed with the typed result

Every concrete Error subclass guarantees the contract:

Member Description

code

Numeric error code from the type’s enum (e.g. FileErr., WONErr.).

message

Human-readable diagnostic, may be null.

toString

Format with the subclass name as prefix.

Type-specific extras (FileError.errno, WONError.offset, ConnError.bytesSent, SFTPError.serverStatus, SFTPError.isTransient) live on the subclass.

Error itself is empty by design: foreign subclasses (FileError etc.) need the parent to be field-free or Wren rejects the inheritance at module-load time (the numFields == -1 && superclass→numFields > 0 check at wren_vm.c:559-563).

ScriptError

Concrete Wren-side base for script-defined error types. Use this when a script wants to surface its own typed error without writing C bindings:

import "syncterm" for ScriptError

class ConfigError is ScriptError {}

if (something_bad) {
  return ConfigError.new(42, "missing required field 'host'")
}

ScriptError provides the same code / message / toString getters every foreign Error subclass exposes, so callers see uniform behaviour from is Error and the field accesses regardless of whether the value came from C or Wren. The default toString uses the runtime class name ("ConfigError(42): missing required field 'host'"); subclasses may override for a different format.

Member Description

ScriptError.new(code, message)

Construct. code is a Num, message a String (or null).

code

The integer passed to the constructor.

message

The string passed to the constructor.

toString

"<className>(<code>): <message>".

Screen

Read/write access to the terminal display surface. Screen itself exposes whole-screen and absolute-coordinate operations; per-window operations live under Screen.window.

Member Description

Screen.size

[width, height] of the entire screen.

Screen.save()

Save the entire screen contents. Returns an opaque handle.

Screen.restore(handle)

Restore a previously saved screen.

Screen.modalRun(fn)

Run fn with the screen and the wire-byte pump under our own control: snapshot the screen + suspend CTerm before the call, unsuspend + restore the screen after. The previous CTerm.suspended value is saved and restored, so nested modalRun calls compose. Returns whatever fn returned. No Fiber.try wrapper — fn runs on the calling fiber so the async App.run event loop (which Fiber.yield`s from `drainOnce_) works unmodified; an abort inside fn therefore skips cleanup and propagates. Bodies that need abort-safe cleanup should wrap themselves in a Fiber.try at the boundary they own. + [source,wren] ---- Screen.modalRun(Fn.new { var app = App.new() // …​ configure app …​ app.runSync() }) ---- + Without this, every modal-screen script duplicates the same four-line dance (Screen.save, CTerm.suspended = true, work, unsuspend + restore) — and forgetting the suspended toggle leaks remote bytes onto the modal screen the next time the BBS sends something.

Screen.readRect(sx, sy, ex, ey)

Read a rectangle of cells (1-based, inclusive). Returns a Surface sized (ex-sx+1) × (ey-sy+1), or null on invalid coords / OOM.

Screen.writeRect(sx, sy, ex, ey, src)

Write src into a rectangle. src may be a Surface (whose count must equal the region size; the contiguous buffer is blitted directly with no copy) or a List of Cell objects. Aborts the calling fiber on a size or type mismatch.

Screen.putRect(src, dstX, dstY), Screen.putRect(src, srcRect, dstX, dstY)

Blit a Surface to the screen at (dstX, dstY) (1-based). The 4-arg form takes a Rect sub-rect of src; the 3-arg form blits the whole source. Out-of-screen regions are clipped silently.

Screen.moveRect(sx, sy, ex, ey, dx, dy)

Copy a rectangle to a new position.

Screen.attr, Screen.attr=(a)

Read or set the active text attribute byte.

Screen.hyperlinkId, Screen.hyperlinkId=(id)

Hyperlink ID attached to subsequent window writes. 0 clears. Allocate IDs through Hyperlinks.add.

Screen.supports, Screen.font, Screen.palette, Screen.cursor, Screen.videoFlags, Screen.color, and Screen.window are getters that return the helper class so the script can call its static members.

Screen.supports

Read-only Boolean capability flags reflecting what the current display backend (SDL, X11, Win32, etc.) can do. Scripts that adapt their output to the backend should branch on these.

Flag True when the backend can…

loadableFonts

Replace the bitmap font in any slot at runtime. False on hardware text-mode backends like curses.

altBlinkFont

Render the alternate-blink font slot.

altBoldFont

Render the alternate-bold font slot.

brightBackground

Render the high-intensity (bright) bit on background colors instead of using it as a blink toggle.

paletteChange

Mutate the active palette at runtime.

pixels

Address individual pixels (pixel-aware backends only — needed for RIP, SIXEL, SkyPix).

customCursor

Render a non-default cursor shape.

fontSelection

Switch fonts via the SyncTERM font-selection UI.

windowTitle

Set the OS window title.

windowName

Set the OS window’s class / icon name (X11).

windowIcon

Set the OS window’s icon image.

extendedPalette

Address palette indices beyond 16.

blockyScaling

Use nearest-neighbour scaling for the pixel-doubled display.

externalScaling

Defer scaling to the OS / window manager.

closeLock

Inhibit the OS window close button while a BBS session is active.

Screen.window

Operations scoped to the active text window. Cursor position, character output, line edits, and clearing all act inside the window’s rectangle. Coordinates are window-relative (1, 1 is the top-left of the window, not the screen).

Member Description

Screen.window.bounds, bounds=(box)

[sx, sy, ex, ey] of the active window (screen-absolute, 1-based, inclusive).

Screen.window.position, position=(coord)

[x, y] cursor position, window-relative.

Screen.window.putChar(c)

Write one character at the cursor.

Screen.window.print(s)

Write a string at the cursor.

Screen.window.clear()

Clear the window with the current attribute.

Screen.window.clearToLineEnd()

Clear from cursor to end of line.

Screen.window.deleteLine(), insertLine(), scroll()

Line editing inside the window.

Screen.font

Per-slot font management. Five slots (0..4) match SyncTERM’s font slot model.

Screen.font[i] reads the font index in slot i. Screen.font[i] = n loads font n into slot i. Use the Font class (see Font) for the named built-in indices.

Screen.palette

24-bit-RGB palette as 0xRRGGBB integers.

Screen.palette[i] and Screen.palette[i]=(c) cover the full palette range.

Screen.palette.mode and Screen.palette.mode=(list) get/set the 16-color legacy-attribute palette as a List of integers.

Note
term.c, cterm.c, and ripper.c all do their own palette manipulations. Concurrent edits from a script and from the terminal code may not compose cleanly.

Screen.cursor — CustomCursor

Cursor geometry: scanline range, blink rate, visibility.

The class supports two usage modes:

  • Static (live state): every static property reads or writes the cursor immediately. Screen.cursor.blink = false hides the blink on the next refresh. Each setter under the hood does a full read-modify-write of the renderer’s cursor state — so a single static write IS atomic for that one field, but a chain of static writes is NOT atomic across fields. The renderer can paint between them, briefly showing intermediate states. For multi-field changes, use the instance path.

  • Instance (staged edit): CustomCursor.new() (or CustomCursor.current) snapshots the current values. Modify the instance’s properties locally, then apply() to commit atomically — one syscall regardless of how many fields changed.

CustomCursor.preserve(fn) is a convenience for the common "snapshot, change, restore" pattern: snapshots the live cursor, runs fn, re-applies the snapshot. Same fiber semantics as Screen.modalRun — no Fiber.try wrapper, an abort in fn skips the restore.

Properties (both static and instance): startLine, endLine, range, blink, visible.

Pre-defined snapshots:

Snapshot Equivalent legacy code

CustomCursor.normal

Mode-default cursor shape (legacy code 2).

CustomCursor.solid

Solid block cursor (legacy code 1).

CustomCursor.none

Hidden cursor (legacy code 0).

Screen.videoFlags — VideoFlags

Boolean video-output flags, with the same static-vs-instance pattern as CustomCursor — including the chained-static-writes non-atomicity caveat and the VideoFlags.preserve(fn) snapshot helper.

Property Meaning

altChars

Use the alternate (right-half) character set for graphics characters — the EGA/VGA "9th column" expansion.

noBright

Suppress the bright-foreground bit; bold text renders as plain.

bgBright

Treat the high-intensity bit on the background nibble as bright background instead of blink. The terminal’s tradition was blink; most modern terminals bind it to bright bg.

blinkAltChars

Repurpose the blink attribute bit (bit 7 of the legacy attribute byte) to select the alternate character set instead of triggering blink. When set, "blinking" cells render from the alt-font slot statically; when clear, bit 7 means actual blink.

noBlink

Disable blink rendering entirely.

expand

Read-only. True when the backend is using 9-pixel-wide cells with the right-edge duplication for the box-drawing range. Flipping it under a live screen would mismatch cell-bitmap layout sized at video- mode init for 8-vs-9 pixel cells, so it’s intentionally read-only.

lineGraphicsExpand

Apply the right-edge duplication to box- drawing characters in the 0xC0..0xDF range (the "VGA 9th-column" trick).

Screen.color — Color

Build and inspect 24-bit RGB values. Returned values are raw 0xRRGGBB without the high RGB-mode bit; the bit is added when writing into a Cell field via fgRgb= / bgRgb=.

Method Description

Color.fromRgb(r, g, b)

Pack three 8-bit components into a uint32.

Color.fromAttr(attr)

Decode an attribute byte into [fg, bg] 24-bit RGB.

Color.toLegacyAttr(fg, bg)

Encode two palette indices into one attribute byte.

Input

Unified blocking and nonblocking reader for keyboard and mouse events.

Member Description

Input.next()

Block until the next event; return KeyEvent, PhysicalKeyEvent, or MouseEvent.

Input.next(ms)

Wait up to ms milliseconds; return the event or null on timeout.

Input.poll()

Return immediately with the next pending event, or null if nothing is pending.

Input.unget(ev)

Push an event back to the front of the input queue. Routes by event type — KeyEventungetch, PhysicalKeyEvent → physical-key synthesis, MouseEventungetmouse.

Input.synthesizePhysicalKey(evdev, pressed)

Synthesize a physical key edge. evdev is an evdev key code number; pressed is true for press and false for release. Duplicate edges are suppressed by ciolib’s held-key tracking. Has no effect on builds without physical-key event support.

Input.mousedrag(), Input.mousedrag(forceRect)

Hand off to SyncTERM’s full-screen drag-select gesture. This uses the current screen dimensions and does not depend on a connected terminal. forceRect=true forces rectangular select (line-mode select makes no sense across Wren UI chrome).

Input.mouseVisible=(b)

Show or hide the mouse cursor. Setter only — there is no ciolib query for visibility, and tracking it from Wren would drift if other code shows or hides the cursor.

Input.mouseEvents, Input.mouseEvents=(mask)

Read or set the bitmask of Mouse. events the terminal will deliver. UI code typically saves the current mask, *replaces it with exactly the events it cares about, and restores on exit so the surrounding terminal’s mouse mode survives.

Input.enableMouseEvent(ev), Input.disableMouseEvent(ev)

Single-event toggles for the same mask.

Input.setupMouseEvents()

Reconfigure ciolib to report exactly the events appropriate for cterm’s current mouse mode (MM_OFF clears everything; the various tracking modes each register their own button / move set), then call showmouse(). Wraps the C-side setup_mouse_events(&ms)
showmouse() pair — call after toggling CTerm.mouseDisabled or otherwise mutating mouse state from a script.

Input.pushClaim(fn)

Push fn onto the input claim stack as the current claim for the calling fiber. fn(ev) is invoked by the C-side dispatcher with the next key, physical-key, or mouse event and must return Bool — true consumes, false passes through to the next claim down (and ultimately to registered Hook.onKey / Hook.onPhysicalKey / Hook.onMouse). Per-fiber slot: a same-fiber re-push replaces the existing claim in place; a different-fiber push adds a new entry on top. Returns a ClaimHandle. See Modal Input.

Once a process-close request has been read, Input.next(), Input.next(ms), and Input.poll() continue returning Key.quit until SyncTERM exits. UI code should use App, which reserves the event for call-stack unwinding and supports the explicit Widget.atExit exception.

For posting a synthetic resume to a parked fiber (the wake mechanism that Hook.onInput-style callbacks use to nudge a UI fiber), see Wake.

KeyEvent

Wraps a 16-bit ciolib key code.

Member Description

KeyEvent.new(code)

Construct from a raw 16-bit code. Aborts the calling fiber if code is not roundtrip-representable through ciolib’s byte-stuffed key transport — see Encoding constraint below.

code

Raw 16-bit ciolib code.

codepoint

Unicode value of the byte under the current Font.codepage for non-extended keys; null for extended keys (high byte non-zero).

text

UTF-8 form of codepoint, or "" for extended keys.

Encoding constraint

ciolib transports 16-bit key codes through getch / ungetch as a low-byte then high-byte sequence, with the convention that the first byte being 0x00 or 0xE0 means "extended scancode follows." The corollary: a code with high byte non-zero AND low byte not 0x00 / 0xE0 cannot be represented in that stream — Input.unget would split it into two separate, unrelated reads.

KeyEvent.new(code) therefore accepts only:

  • High byte 0x00 — single-byte ASCII / printable keys (KeyEvent.new(0x001B) for Esc, KeyEvent.new(0x0041) for A).

  • Low byte 0x00 — extended scancode-only keys (KeyEvent.new(0x3B00) for F1, KeyEvent.new(0xFA00) for an arbitrary synthetic sentinel).

  • Low byte 0xE0E0-prefixed extended keys (KeyEvent.new(0x29E0) for the Wren console hotkey).

Any other combination (KeyEvent.new(0xFB01), KeyEvent.new(0x1234)) aborts the calling fiber with a clear error.

The CIO_KEY_ABORTED scancode (0x01E0, "Esc by scancode") is normalized to plain Esc (0x001B) inside the constructor, so scripts only ever see one Esc value.

PhysicalKeyEvent

Represents one physical key press or release edge using the same evdev key code namespace as CTerm’s physical-key reporting protocol. Returned by Input.next() / Input.poll(), passed to Input.pushClaim handlers, and passed to Hook.onPhysicalKey callbacks.

Member Description

PhysicalKeyEvent.new(evdev, pressed)

Construct a synthetic physical-key edge. evdev must be in the evdev key-code range; pressed is true for press and false for release.

evdev

evdev key code number.

pressed

true for press, false for release.

MouseEvent

Mirrors struct mouse_event from ciolib.h. Returned by Input.next() / Input.poll(), passed to Input.pushClaim handlers, and passed to Hook.onMouse callbacks — every mouse surface uses this same shape.

Member Description

MouseEvent.new(event, startX, startY)
MouseEvent.new(event, startX, startY, modifiers)
MouseEvent.new(event, startX, startY, endX, endY)
MouseEvent.new(event, startX, startY, endX, endY, modifiers)
MouseEvent.new(event, startX, startY, endX, endY, modifiers, bstate)

Construct from raw fields. Five overloads dispatched by arity. Omitted endX / endY default to startX / startY (a zero-extent point event); omitted modifiers defaults to 0; omitted bstate is derived from event — the bit for the button this event is about, per Mouse / CIOLIB_BUTTON(n), or 0 for Mouse.move. The seven-arg form is the canonical fully-explicit constructor.

event

Mouse event type code.

bstate

Snapshot of the button-state bitmask at the moment the event fired (the bstate field on struct mouse_event). Lets a drag-end handler check which other buttons were also held.

modifiers

Keyboard modifier mask.

startX, startY

Click-down coordinates (1-based).

endX, endY

Release coordinates (1-based).

Mouse event constants

Mouse.button1*, Mouse.button2*, and Mouse.button3* identify left, middle, and right button events. Each family uses the ciolib order Press, Release, Click, double/triple/quadruple Click, DragStart, DragMove, and DragEnd; the Wren module currently names every button-1 event and the button-2 and button-3 Press, Release, and Click events. Mouse.wheelUpPress, Mouse.wheelUpClick, Mouse.wheelDownPress, and Mouse.wheelDownClick cover the backend-dependent wheel forms.

ClaimHandle

Returned from Input.pushClaim. Drop the claim by calling pop().

Member Description

pop()

Remove the claim entry from the stack. Idempotent — stale handles (after a same-fiber re-push bumped the entry’s id, or after another path popped it, or after the foreign finalizer ran) no-op silently. The C-side foreign finalizer also pops on GC, so a forgotten handle still cleans up — but explicit pop is cheaper and more predictable.

Wake

Fiber-resume primitive. Wake.post(fiber, value) queues a synthetic resume of fiber, delivering value as the return of its next Fiber.yield(). The wake is enqueued on the same result queue Timer.trigger and SFTP completions use, so it’s delivered on the next main-loop drain — never in the middle of the current foreign call. Safe to call from any foreign context (hooks, claim handlers, worker fibers). value may be any Wren object — the deliverer pins it as a WrenHandle until delivery, then loads it into the fiber’s resumed-value slot. Multiple posts queue up; each becomes one resumption in arrival order.

Method Description

Wake.post(fiber, value)

Queue a resume. Returns immediately; the wake is dispatched on the next main-loop drain. Convention: pass a discriminated value the fiber’s main-loop demuxer can recognise (a foreign KeyEvent / MouseEvent class, or your own sentinel). Do NOT call from inside another foreign method whose caller is about to re-enter wrenCall (same rule as wrenCall itself — see wren.md §7); hooks and claim handlers are the canonical safe sites.

Key

16-bit key codes returned by KeyEvent.code.

Printable ASCII characters (digits, letters, punctuation) come through directly as their ASCII byte value with the high byte zero — e.g. uppercase A is 0x0041 (65) — and don’t need a named constant. The constants below cover non-printable keys, modified keys, function keys, and synthetic markers.

The high byte is the PC scancode (or a synthetic marker > 0x7D when no scancode applies); the low byte is 0 for extended keys. ASCII keys use the low byte for the character and 0 for the high byte.

ASCII keys

Constant Code Description

escape

0x001B

Esc. KeyEvent.new also normalises the synthetic CIO_KEY_ABORTED (0x01E0, "Esc-by-scancode") to this value, so scripts only ever see one Esc.

enter

0x000D

Enter / Return (CR).

backspace

0x0008

Backspace (BS).

tab

0x0009

Tab (HT).

delChar

0x007F

ASCII DEL — distinct from delete (the Del key). Some terminals send DEL on Backspace; cterm’s decBkm flag controls which a script sees.

Cursor and editing keys

Constant Code Description

home

0x4700

Home.

end

0x4F00

End.

up

0x4800

Up arrow.

down

0x5000

Down arrow.

left

0x4B00

Left arrow.

right

0x4D00

Right arrow.

pageUp

0x4900

Page Up.

pageDown

0x5100

Page Down.

insert

0x5200

Insert (Ins).

delete

0x5300

Delete (Del) — the keyboard key, distinct from delChar (the ASCII byte 0x7F).

backTab

0x0F00

Shift+Tab (Back-Tab).

Modified Insert / Delete

Constant Code Description

shiftIns

0x0500

Shift+Insert.

shiftDel

0x0700

Shift+Delete.

ctrlIns

0x0400

Ctrl+Insert.

ctrlDel

0x0600

Ctrl+Delete.

altIns

0xA200

Alt+Insert.

altDel

0xA300

Alt+Delete.

Modified arrow keys and End

Constant Code Description

shiftUp

0x91E0

Shift+Up.

ctrlUp

0x8D00

Ctrl+Up.

altUp

0x9800

Alt+Up.

shiftDown

0x96E0

Shift+Down.

ctrlDown

0x9100

Ctrl+Down.

altDown

0xA000

Alt+Down.

shiftLeft

0x93E0

Shift+Left.

ctrlLeft

0x7300

Ctrl+Left.

shiftRight

0x94E0

Shift+Right.

ctrlRight

0x7400

Ctrl+Right.

shiftEnd

0x95E0

Shift+End.

ctrlEnd

0x7500

Ctrl+End.

Function keys

f1 through f12 and the modified variants shiftF1..shiftF12, ctrlF1..ctrlF12, altF1..altF12. Code values are contiguous within each group, but the absolute values are scancode- derived and aren’t worth memorising. Compare against the named constant.

Key Plain Shift Ctrl Alt

F1

0x3B00

0x5400

0x5E00

0x6800

F2

0x3C00

0x5500

0x5F00

0x6900

F3

0x3D00

0x5600

0x6000

0x6A00

F4

0x3E00

0x5700

0x6100

0x6B00

F5

0x3F00

0x5800

0x6200

0x6C00

F6

0x4000

0x5900

0x6300

0x6D00

F7

0x4100

0x5A00

0x6400

0x6E00

F8

0x4200

0x5B00

0x6500

0x6F00

F9

0x4300

0x5C00

0x6600

0x7000

F10

0x4400

0x5D00

0x6700

0x7100

F11

0x8500

0x8700

0x8900

0x8B00

F12

0x8600

0x8800

0x8A00

0x8C00

Synthetic markers

These are SyncTERM-specific codes injected into the key queue rather than scancodes from a real key.

Constant Code Description

mouse

0x7DE0

A mouse event is next in the queue. When KeyEvent.code == Key.mouse, call Input.next() again to read the MouseEvent itself. The Schneider/Amstrad PC1512 "F-14" key encoded as right-mouse — SyncTERM reuses that codepoint.

quit

0x7EE0

The user requested quit (window close button, OS close signal). This is a sticky host request; App handles it before ordinary widget or keymap dispatch. Modeled on the PC1512 "F-15" codepoint.

wrenConsole

0x29E0

Ctrl+` — opens the Wren console. High byte is the `-key scancode (0x29).

Clipboard

Member Description

Clipboard.text

Read the system clipboard as a string.

Clipboard.text=(s)

Write a string to the system clipboard.

Codepage

Enum-style class identifying a character-set encoding. Returned by Font.codepage and Font.codepageOf(i) to tell scripts which encoding the bytes in a screen cell are in.

A _b suffix on a codepage name means "broken vertical" — the font variant draws box-drawing pipe characters with one-pixel gaps between rows. The base codepage and its _b variant cover the same character set; only the box-drawing glyph shapes differ.

Constant Index Description

cp437

0

IBM PC code page 437 — English plus box-drawing. The default for traditional ANSI BBSes.

cp1251

1

Windows-1251 — Cyrillic (Russian, Bulgarian, Ukrainian, etc.) in a "swiss" font variant.

cp1251_b

2

Windows-1251 with the broken-vertical glyph variant.

koi8r

3

KOI8-R — Russian Cyrillic. Common on 1990s Russian Linux/Unix systems.

iso8859_2

4

ISO-8859-2 (Latin-2) — Central European (Polish, Czech, Hungarian, …).

iso8859_4

5

ISO-8859-4 (Latin-4) — Northern European / older Baltic. 9-bit-mapped wide-glyph variant.

cp866m

6

CP866 — Russian DOS, "(c)" modified variant.

iso8859_9

7

ISO-8859-9 (Latin-5) — Turkish.

iso8859_8

8

ISO-8859-8 — Hebrew.

koi8u

9

KOI8-U — Ukrainian Cyrillic.

iso8859_15

10

ISO-8859-15 (Latin-9) — Western European with the Euro sign.

iso8859_5

11

ISO-8859-5 — Latin/Cyrillic.

cp850

12

CP850 — Multilingual Latin-1 (DOS Western European).

cp850_b

13

CP850 with the broken-vertical glyph variant.

cp865

14

CP865 — Nordic DOS (Danish, Norwegian).

cp865_b

15

CP865 with the broken-vertical glyph variant.

iso8859_7

16

ISO-8859-7 — Greek.

iso8859_1

17

ISO-8859-1 (Latin-1) — Western European, classic Unix default.

cp866m2

18

CP866 — Russian DOS, second modified variant.

cp866u

19

CP866-U — Ukrainian variant.

cp1131

20

CP1131 — Belarusian DOS.

armscii8

21

ARMSCII-8 — Armenian.

haik8

22

HAIK-8 — older Armenian set; used with the ARMSCII-8 screen map.

atascii

23

ATASCII — Atari 8-bit native character set.

petsciiUpper

24

PETSCII (uppercase/graphics mode) — Commodore 64/128 default.

petsciiLower

25

PETSCII (lowercase/shifted mode) — Commodore 64/128 after a Shift+Commodore key.

prestel

26

UK Prestel / Viewdata teletext — block mosaic graphics, double-height, conceal.

prestelSep

27

Prestel separated mosaics — block mosaics with gaps between cells.

atariSt

28

Atari ST GEM character set.

Cell

Wraps a struct vmem_cell: one screen cell’s character, attribute, and color state. Use cases: read a rectangle through Screen.readRect, build a list manually, then write it back through Screen.writeRect.

Member Description

Cell.new()

Construct an empty cell.

ch, ch=(s)

Character as a Unicode string (one codepoint, round-tripped through CP437).

chByte, chByte=(n)

Character as a raw 8-bit byte.

font, font=(n)

Font slot.

legacyAttr, legacyAttr=(n)

Legacy attribute byte (foreground in low nibble, background in high nibble, plus bright/blink bits).

bright, bright=(b)

Bright bit.

blink, blink=(b)

Blink bit.

fgPalette, bgPalette

Foreground / background palette indices.

fgRgb, bgRgb

Foreground / background as 24-bit RGB. Setters preserve bits 24..30 of the existing field, so toggling between palette and RGB modes does not lose unrelated state.

hyperlinkId, hyperlinkId=(n)

Hyperlink ID attached to this cell.

eqContent(other)

Structural equality of every content field. This is not a == override — cell == otherCell stays foreign-identity-based, like every other foreign in the API. Call eqContent explicitly when you want to know "do these two cells have the same content?" (e.g. `surf.where {

c

c.eqContent(template) }`). + Returns false for non-Cell other (no fiber abort), false if either cell flies the pixel-graphics flag (the actual pixel data lives outside the vmem_cell and isn’t compared), and ignores the internal BG dirty bit (render bookkeeping, not content). All other bits, including the Prestel control char in fg and the double-height/Prestel-mode/etc. flags in bg, are compared bit-exact. + Note that Cell cannot be a Map key — Wren’s Maps reject any foreign object regardless of == definitions (wren_value.h:880-888). Defining a structural == would not lift that restriction; that’s why we expose this as a named method instead.

Surface

A width × height grid of Cells backed by a contiguous vmem_cell buffer. Surface is Sequence, so the linear [i] subscript, count, and the standard sequence helpers (each, where, map, all, any, reduce, join, take, skip, toList, …) all work — handy for "tell me where any 'X' lives" without switching to 2D indexing. Linear order is row-major: buf[y * width + x].

Screen.readRect returns a Surface sized to the requested rect; Screen.putRect blits a Surface to the screen as a single atomic puttext; Screen.writeRect accepts a Surface as its source. The UI library uses Surfaces as per-widget backbuffers and composites them into a screen-sized backbuffer Surface that’s blitted once per frame.

Member Description

Surface.new(width, height)

Allocate a fresh Surface of the given size. Cells start zeroed (NUL char, attribute 0, slot 0, no hyperlink).

width, height

Dimensions in cells.

count

width * height — total cell count. From the Sequence protocol.

[i]

Linear-indexed read of the cell at offset i. Returns a Cell view onto the Surface’s buffer (mutating the cell mutates the Surface).

cellAt(x, y)

2D-indexed Cell view (0-based). Returns null if (x, y) is outside [0, width) × [0, height).

iterate(it), iteratorValue(it)

Sequence protocol — yields each cell linearly in row-major order (the cell at (x, y) appears at iteration index y * width + x).

rows

Sequence-of-Sequence view, outer-by-y. surf.rows has length surf.height and yields SurfaceRow instances; each row has length surf.width and yields Cell`s left-to-right. Use for the natural row-major nested loop: + [source,wren] ---- for (row in surf.rows) { for (cell in row) …​ } ---- + Single-cell access: `surf.rows[y][x] (equivalent to surf.cellAt(x, y)).

cols

Sequence-of-Sequence view, outer-by-x. surf.cols has length surf.width and yields SurfaceCol instances; each column has length surf.height and yields Cell`s top-to-bottom. Use when column-major traversal reads cleaner: + [source,wren] ---- for (col in surf.cols) { for (cell in col) …​ } ---- + Single-cell access: `surf.cols[x][y]. + Both rows and cols are lazy views — they hold a reference to the Surface and fetch cells on demand via cellAt, so mutating a cell through surf.rows[y][x] mutates the underlying Surface.

putRect(src, dstX, dstY), putRect(src, srcRect, dstX, dstY)

Paste a source Surface into this Surface at (dstX, dstY) (0-based). The 4-arg form copies a sub-rect of src defined by a Rect. Out-of-bounds writes are clipped.

fill(rect, cell)

Bulk-fill the cells inside rect (a Rect) with a copy of cell.

toString

Debug-only string form, e.g. "Surface(80x25)". Format may change.

Font

A "font" in SyncTERM is one of 257 indexed slots, each a complete glyph table for a particular character set or display style. Slots 0–45 ship with built-in fonts, covering the codepages enumerated by Codepage plus several Amiga and 8-bit display styles. Slots 46+ are reserved for user-loaded fonts (uploaded via cterm escape sequences or RIP), but Font.count reports however many slots are actually populated at runtime.

The Font class itself only exposes named constants for the most useful slots; every slot is reachable numerically through Screen.font[i] regardless. The full built-in table follows.

Named constant Index Slot description

Font.cp437English

0

Codepage 437 English (default).

1

Codepage 1251 Cyrillic, "swiss" variant.

2

Russian KOI8-R.

3

ISO-8859-2 Central European.

4

ISO-8859-4 Baltic wide (VGA 9-bit mapped).

5

Codepage 866 (c) Russian.

6

ISO-8859-9 Turkish.

7

HAIK-8 (used with the ARMSCII-8 screen map).

8

ISO-8859-8 Hebrew.

9

Ukrainian KOI8-U.

10

ISO-8859-15 West European, "thin" variant.

11

ISO-8859-4 Baltic (VGA 9-bit mapped).

12

Russian KOI8-R, alternate (b) variant.

13

ISO-8859-4 Baltic wide.

14

ISO-8859-5 Cyrillic.

15

ARMSCII-8 (Armenian).

16

ISO-8859-15 West European.

17

Codepage 850 Multilingual Latin I, "thin".

18

Codepage 850 Multilingual Latin I.

19

Codepage 865 Norwegian, "thin".

20

Codepage 1251 Cyrillic.

21

ISO-8859-7 Greek.

22

Russian KOI8-R, alternate (c) variant.

23

ISO-8859-4 Baltic.

24

ISO-8859-1 West European.

25

Codepage 866 Russian.

26

Codepage 437 English, "thin" variant.

27

Codepage 866 (b) Russian.

28

Codepage 865 Norwegian.

29

Ukrainian CP866-U.

30

ISO-8859-1 West European, "thin".

31

Codepage 1131 Belarusian, "swiss".

Font.commodore64Upper

32

Commodore 64 (uppercase / graphics).

Font.commodore64Lower

33

Commodore 64 (lowercase / shifted).

Font.commodore128Upper

34

Commodore 128 (uppercase / graphics).

Font.commodore128Lower

35

Commodore 128 (lowercase / shifted).

Font.atari

36

Atari 8-bit.

Font.potNoodle

37

P0T NOoDLE — Amiga BBS classic.

Font.mosOul

38

mO’sOul — Amiga BBS classic.

Font.microKnightPlus

39

MicroKnight Plus — Amiga.

Font.topazPlus

40

Topaz Plus — Amiga.

Font.microKnight

41

MicroKnight — Amiga.

Font.topaz

42

Topaz — Amiga Workbench font.

Font.prestel

43

Prestel — UK Viewdata mosaic.

Font.atariSt

44

Atari ST GEM.

Font.ripterm

45

RIPterm — RIP graphics terminal font.

The "thin" variants are the same character set rendered with thinner strokes in the VGA BIOS style. A "swiss" variant uses a Helvetica-style sans rendering. An "(a)" / "(b)" / "(c)" annotation distinguishes bundled font variants that use the same codepage encoding.

Method Description

Font.name(i)

Human-readable font name for slot i, e.g. "Codepage 437 English". Returns the empty string for empty slots.

Font.count

Total number of populated font slots. Iteration bound — 0…​Font.count walks every available font.

Font.available(i)

true if slot i has actually been loaded (the user can configure SyncTERM to omit some built-ins).

Font.codepage

The Codepage value for slot 0 (whatever codepage the script’s strings should be transcoded into for display).

Font.codepageOf(i)

The codepage value for slot i. Several slots can share a codepage (the Amiga slots 37–42 are all iso8859_1, for example).

Typed-ID lookup table over the active hyperlink registry. Despite the [id] / containsKey(id) shape, this is not a Map — there is no keys / values / count / iteration. IDs flow into a script from add() returns or from Cell.hyperlinkId on cells under the cursor; scripts can’t enumerate the ID space. ciolib doesn’t expose an enumeration primitive either, and no current script needs one. If a caller turns up that wants enumeration, the table can grow keys / count / iterate later without breaking the existing surface (see Console.entries for the pattern).

Member Description

Hyperlinks[id]

URI string for id, or null if id is not in the table.

Hyperlinks.containsKey(id)

true if id is present.

Hyperlinks.add(uri, idParam)

Allocate a new hyperlink ID for uri with optional id parameter string. Returns the new numeric ID.

Hyperlinks.params(id)

Parameter string for id, or null.

To attach hyperlinks to text, allocate an ID and assign it to Screen.hyperlinkId before writing through Screen.window.print.

Console

Read-only access to the current VM’s always-on print/error log buffer. The buffer has 1024 entries of up to 8 KB each; older entries are evicted in FIFO order. Sequence numbers are monotonic across the VM’s lifetime, including across eviction, so an incremental "show only entries newer than X" reader works correctly even after the ring has wrapped.

Member Description

Console.count

Number of valid entries currently in the ring.

Console.total

Monotonic count of entries ever written. Survives clear().

Console[seq]

Entry with monotonic sequence seq, as a [ts, source, text] triple (the seq number is the index you queried with — not duplicated in the value). source is one of the LogSource values. Out-of-range returns null.

Console.clear()

Drop all entries. total keeps incrementing.

Console.markSeen()

Snapshot the current total and error counters as the "seen" baseline. The connected status-bar indicator and menu top-right indicator (yellow for new prints, red for new errors) compare the live counters against this baseline, so calling markSeen() clears the indicator until the next entry arrives. The built-in REPL invokes this on exit so console activity logged while the REPL is open doesn’t leave the indicator lit.

Console.iterate, iteratorValue

Wren iteration protocol — works in for (e in Console) { …​ }.

Console.entries

Sequence-protocol view on the same underlying buffer. Returns a fresh ConsoleEntries instance that delegates count, [seq], and the iteration pair to Console. Use this when you want .map/.where/.toList/.count/.where/etc. — the helpers are defined as instance methods on Sequence, so they need an instance to dispatch on: + [source,wren] ---- var errors = Console.entries.where {

e

e[2] == LogSource.runtimeError }.toList ---- + Console itself stays an all-static namespace — it can’t is Sequence directly because Sequence’s helpers (map, where, …​) are defined as instance methods on this, which a class object can’t satisfy.

LogSource

Enum values for the source field on Console entries.

  • LogSource.print (0) — script System.print.

  • LogSource.compileError (1) — Wren compile diagnostic.

  • LogSource.runtimeError (2) — Wren runtime error message.

  • LogSource.stackFrame (3) — one frame of a runtime-error stack trace.

REPL

Primitive for the immediate-mode REPL. Most scripts will not use this directly; the embedded wren_console script wraps it.

Method Description

REPL.eval(src)

Compile and run src in module syncterm. Returns null for statements; [value] (a one-element list) for expressions.

REPL.eval(module, src)

As above, in module.

REPL.hasModule(name)

true if name has been loaded.

REPL.modules

List of every module name currently loaded in the VM (including core). Order is implementation-defined; sort if you need stable output.

The [value] wrapper for expressions distinguishes "this was a statement" from "this was an expression whose value is `null`".

Runtime errors propagate normally. Wrap the call in Fiber.new {}.try() to catch them.

Conn

Connection-level send and receive.

Member Description

Conn.send(s)

Send bytes through the full path: telnet IAC escaping, then on-wire. Returns null on success or a ConnError on failure (connection down, outbuf full / partial queue). Most scripts ignore the return; ones that care can is ConnError and inspect .bytesSent to retry.

Conn.sendRaw(s)

Send bytes raw, bypassing IAC escaping. Same return contract as send.

Conn.close()

Close the connection.

Conn.endSession(exitApp)

Request a session end. Sets a flag that doterm() picks up at the top of its next iteration, runs the (UI-free) cleanup (cterm shutdown, conn close, mouse/cursor restore), then either returns to the main menu (exitApp == false, Alt-H / Ctrl-Q semantics) or exits syncterm entirely (exitApp == true, Alt-X semantics). Returns immediately — the caller is responsible for any user-facing confirm popup. The default disconnect-key hooks in keys_default.wren raise a Wren-side Confirm popup before invoking this; the online_menu Disconnect / Exit entries call this directly (the menu selection is itself the confirmation).

Conn.paste()

Paste from the system clipboard onto the wire — codepage-aware, bracketed-paste-aware. No-op when the clipboard is empty. This is the operation bound to Shift+Insert by the default key hooks.

Conn.scrollback()

Invoke ScrollbackView.run() in the connected VM. The Wren view temporarily presents cterm’s scrollback ring, supports keyboard, mouse, selection, URL, and help interaction, then restores the live terminal and exact previous mouse-event mask on exit. While active, the viewer replaces remote tracking subscriptions with only its drag and wheel events. This is the default Alt+B path.

Conn.upload()

Open the upload picker or dialog. This is the operation bound to Alt+U by the default key hooks. No-op outside an active session. Mouse events are refreshed on the way out.

Conn.download()

Open the download picker or dialog. This is the operation bound to Alt+D by the default key hooks. No-op outside an active session. Mouse events are refreshed on the way out.

Conn.connected

Bool — is the link up?

Conn.elapsedSeconds

Monotonic seconds since the connection provider reached the active connected state, clamped to 0. This is live connection/session state; BBS.connected is the persisted wall-clock LastConnected timestamp from the BBS list.

Conn.type

ConnType value.

Conn.pending

Bytes available to read right now (in the inbuf ring).

Conn.queued

Bytes queued to write that haven’t gone out yet.

Conn.peek(count)

Look at up to count bytes from the inbuf without consuming. Clamped to the actual ring size.

Conn.recv(count)

Read up to count bytes from the inbuf and consume them.

ConnError

Recoverable wire-side failure returned by Conn.send / Conn.sendRaw. Returned IN PLACE of null on failure; null still means success. Mirrors the SFTPError / FileError / WONError shape.

Member Description

code

ConnErr.* enum value.

bytesSent

For ConnErr.shortSend: number of bytes that did get queued before the outbuf ran out — the script can retry with s[bytesSent..-1] to send the remainder. 0 for other codes.

message

Human-readable diagnostic, may be null.

toString

"ConnError: <CODE-NAME>: …​".

ConnErr constants:

Constant Code When emitted

ConnErr.ok

0

Sentinel; never appears on a returned ConnError.

ConnErr.notConnected

1

Send attempted while Conn.connected == false.

ConnErr.shortSend

2

Outbuf was full / send partially queued.

Capture

Streaming-log control for the active session. The Wren-side CaptureMenu in scripts/auto/connected/capture_menu.wren implements the user-facing Alt+C flow with these primitives. All methods are no-ops when no terminal is active.

Method Description

Capture.active

Bool — true while a streaming log is open (regardless of pause state).

Capture.paused

Bool — true when the active log has been paused via Capture.pause(). Bytes still flow through cterm; just nothing gets written to the log file.

Capture.start(file, raw)

Open a streaming log to the path authorized by file (a write-consent File from Host.pickSavePath). raw == true preserves ANSI escape sequences (post-decode); false strips to plain text. Consumes the File’s write consent. Returns null on success or a FileError on open failure (typically errno = EEXIST for WFC_CREATE if the path was created between pick and open). Aborts the fiber on programmer error (already capturing, no consent on file, etc.).

Capture.stop()

Close the active log. No-op when not capturing. Idempotent.

Capture.pause()

Set the PAUSED bit on the active log. No-op when not capturing or already paused.

Capture.resume()

Clear the PAUSED bit. No-op when not capturing.

Use CTerm.saveScreenshot for a one-shot binary screen snapshot with optional SAUCE metadata. Screen snapshots are terminal state rather than streaming logs and therefore are not part of Capture.

CTerm

Read-only window into cterm state — what term.c and ripper.c read. Useful from Hook.onInput and Status.callable for emulation-aware logic.

Group Members

Cursor (1-based, on the terminal)

x, y

Origin (1-based, on the host screen)

originX, originY

Geometry

width, height, topMargin, bottomMargin, leftMargin, rightMargin

Color state

attr, fgColor, bgColor, hasPaletteOverride, paletteOverride

Fonts

fontSlot, altFonts

Scrollback

scrollbackLines, scrollbackWidth, scrollbackPos, scrollbackStart

Mode flags

emulation (Emulation), doorwayMode, music / music = i (MusicMode enum; setter clamps out-of-range values), started, skypix, statusDisplay (StatusDisplay), atasciiInverse (Bool — ATASCII inverse-video mode), ooiiMode (0 = off, 1..3 = OOTerm / OOTerm1 / OOTerm2). Streaming-log state moved to the Capture class.

Screenshot

saveScreenshot(file, withSauce) — write the cterm area (status bar excluded) as IBM-CGA / BinaryText to file (a write-consent File from Host.pickSavePath), optionally appending a SAUCE block populated from the active BBS. Consumes the File’s write consent. Returns null on success or a FileError on write failure.

Mouse state

mouseMode (raw enum from cterm’s mouse-state struct: 0 = off, 1 = RIP, 9 = X10, 1000..1003 = the various tracking modes), `mouseSgrPixels (Bool — true when DECSET 1016 SGR pixel-position reporting is active), mouseDisabled / mouseDisabled = b (Bool — true when ciolib has been told to stop delivering events even though a tracking mode is set; setter pairs with Input.setupMouseEvents())

Throttle

throttleSpeed (live network character-pacing rate, BPS; 0 = unthrottled), throttleSpeedUp() / throttleSpeedDown() (walk the rates ladder; no-op on serial)

Bitfield snapshots

extAttr (ExtAttr), lastColumnFlag (LastColumnFlag)

Action Description

CTerm.write(s)

Send s to cterm_write — bypass the wire, render directly.

CTerm.suspended, CTerm.suspended = b

Read or write the wire-pump suspend flag. When set true, the main loop stops draining bytes from the conn buffer. Bytes pile up in the conn buffer; the TCP receive window eventually fills and the remote sees its send() calls block or EAGAIN. Use this to claim the screen for a modal dialog, transfer overlay, etc., without remote output painting underneath. Clear it again when you’re done. Pushing an Input.pushClaim does not suspend on its own — scripts that want modal behavior must set this explicitly. When speed emulation is active and the flag transitions from true back to false, the byte pump is credited with all the bytes that would have processed at the emulated rate during the suspended interval; those bytes drain past the speed gate as fast as the pump can run, so the visible output catches up to where it would have been if the suspend had never happened.

CTerm.sftpActive, CTerm.sftpActive = b

SFTP-active flag the SSH driver reads to decide whether to keep the SSH session alive when the shell channel closes (or wire goes idle). Set true while a script has SFTP transfers in flight; set false when the queue drains. is_connected() ORs this with the C-side queue state, so the term.c main loop keeps pumping (Wren VM, paint, drainOnce_) while the flag is true even after the shell channel dies — letting transfers finish in the background workers and disconnecting cleanly when they’re done. Generic — any SFTP-using script can hold the session up.

CTerm.refreshStatus()

Force a SyncTERM status-bar repaint on the next update_status pass. Useful after Screen.restore() puts a stale snapshot on the status row: this causes the bar’s live state (speed, transfer arrows, log indicators) to be re-emitted instead of showing whatever was painted into the saved snapshot. No-op when the bar is disabled (statusDisplay == 0).

CTerm.mouseDisabled, CTerm.mouseDisabled = b

Read or toggle the MS_FLAGS_DISABLED bit on the live mouse_state. true stops ciolib from delivering motion / click events even when a tracking mode is set; false re-enables them. The reconfiguration doesn’t take effect until you follow up with Input.setupMouseEvents().

CTerm.mouseMode

Read-only enum int from the live mouse_state0 (MM_OFF), 1 (MM_RIP), 9 (MM_X10), 1000..1003 (the XTerm tracking modes). Used by the default status bar to decide whether to light the "M" indicator.

CTerm.mouseSgrPixels

Read-only Bool from the live mouse_state; true while DECSET 1016 is the active SGR pixel-position reporting mode.

CTerm.doorwayMode, CTerm.doorwayMode = b

Read or write the cterm doorway-mode flag. When true, the input pump passes most keys through verbatim instead of decoding them; used by door programs that want raw scancodes.

CTerm.ooiiMode, CTerm.ooiiMode = n

Read or write the Operation Overkill ][ tone-emulation mode (0 = off, 1..Host.maxOOIIMode = the OOTerm modes). Setting out-of-range values is clamped. The setter toggles the xptone audio context to match. Always reads 0 and is a no-op in builds compiled with WITHOUT_OOII.

CTerm.throttleSpeed, CTerm.throttleSpeed = bps

Current network character-pacing rate in BPS. 0 = unthrottled (no per-byte sleep). Always 0 on serial connections; the configured port speed lives in BBS.bpsRate. Default status bar reads this to render the live rate; the online_menu Output Rate picker writes via the setter.

CTerm.throttleSpeedUp(), CTerm.throttleSpeedDown()

Walk throttleSpeed one step up or down the BBS-list rates ladder (300, 600, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 76800, 115200). Up from 0 jumps to 300; Down from 300 drops to 0 (unthrottled). No-op on serial connections. Default keys script binds these to Key.altUp / Key.altDown.

ExtAttr

Snapshot of cterm’s extended-attribute bitfield. All members are read-only Booleans reflecting the corresponding terminal mode.

Member Description

autoWrap

DECAWM — characters past the right margin wrap to the next line.

originMode

DECOM — cursor coordinates are relative to the active scroll region rather than the screen.

sxScroll

SIXEL scroll: when a SIXEL image extends past the bottom of the screen, scroll the screen up to make room. When clear, the image is clipped at the bottom row.

decLrmm

DECLRMM — left/right margin mode enables horizontal scroll regions.

bracketPaste

DEC mode 2004 — pasted text is bracketed by ESC[200~ / ESC[201~ markers.

decBkm

DECBKM — Backarrow key sends BS (0x08) instead of DEL (0x7F).

prestelMosaic

Prestel mosaic graphics character set is active.

prestelDoubleHeight

Current row is rendered double-height.

prestelConceal

Concealed (hidden) text mode.

prestelSeparated

Separated mosaics — gaps between mosaic blocks.

prestelHold

Hold-graphics mode — control codes render as the most recently emitted mosaic glyph rather than blanking.

alternateKeypad

DECKPAM — numeric keypad sends application keypad codes instead of digits.

LastColumnFlag

Snapshot of cterm’s last-column-flag state. Used by emulations that distinguish "cursor at the right margin" from "cursor wrapped to next line" (the so-called phantom-column).

Member Description

set

Bool — the flag is currently set: the cursor sits at the right margin awaiting the next character to wrap it. Cleared by any cursor-positioning sequence.

enabled

Bool — last-column-flag tracking is enabled at all. Some emulations disable it.

forced

Bool — tracking is forced on regardless of the underlying mode (set by SyncTERM’s emulation-bridge code or the BBS list’s forceLcf flag).

StatusDisplay

Referenced by CTerm.statusDisplay. VT320 DECSSDT (Select Status Display Type) values: which status line, if any, the bottom row is acting as.

Constant Code Description

none

0

No status line — the bottom row is part of the main display area.

indicator

1

Indicator status line — terminal-managed diagnostic line (e.g. "INSERT", connection state). The host can’t write to it.

host

2

Host-writable status line — the bottom row is a separate addressable region the BBS can direct writes to via DECSASD.

Connected BBS

Read-only window into the active struct bbslist entry. Useful from onConnect-style top-level code that wants to behave differently per BBS.

Most fields are direct getters that return the underlying value. Fields that index into enums are surfaced both as the integer (for direct comparison with the enum class) and through the typed enum classes documented below.

Group Fields

Identity

name, addr, port, connType (ConnType), connTypeName (display string for connType), comment, type (BBSListType), id

Network

addressFamily (AddressFamily)

Auth

user, password, syspass, sftpPublicKey, sshFingerprint, sshFingerprintLen

Display

termName, screenMode (ScreenMode), bpsRate, font, noStatus, hidePopups, yellowIsYellow, forceLcf, palette, paletteSize

Modem / serial

stopBits, dataBits, parity (Parity), flowControl (FlowControl)

Telnet

telnetNoBinary, deferTelnetNegotiation, ghostProgram

RIP / music

rip (RipVersion), music (MusicMode)

File transfer

dlDir, ulDir, xferLogLevel (LogLevel), telnetLogLevel (LogLevel)

Logs

logFile, appendLogFile

Statistics

added, connected, calls, sortOrder

This BBS class belongs to the shared syncterm module and always means the one active connection. It is unrelated to the mutable menu BBS class below; the two classes live in different modules and VMs and no instance can cross between them.

The menu VM imports Menu, BBS, and MenuReadStatus from syncterm_menu. Menu owns a C-side transactional view of the personal, installed system, and web-cache BBS lists. It is not available in a connected VM.

Method Description

Menu.load(password)

Load all lists and replace the current model only after the complete load succeeds. password is a String or null. Returns a MenuReadStatus code. Any password, decryption, parse, or migration failure leaves the current model and its generation unchanged.

Menu.quickConnect(url)

Parse a String using SyncTERM’s normal quick-connect URL rules and return an unsaved transient BBS, or null when no usable address was produced. A later quickConnect or successful load makes an older transient handle stale. It can be returned from MainMenu.run, but save(), rename(), and delete() return false; use Menu.create for a durable entry.

Menu.offlineScrollback

Return a detached Surface copy of the most recently completed session’s linear scrollback, or null when none exists. The copy is safe to retain across later connections; it does not borrow the process buffer which the next session may resize or replace.

Menu.prepareOfflineScrollback()

Select the completed session’s text mode, restore its mode-specific fonts and palette, and draw the terminal background. Returns false when no scrollback exists or the background cannot be drawn. This changes global conio state; bracket it with Screen.save() and Screen.restore().

Menu.offlineScrollbackHasStatus

Return whether the completed session’s terminal layout reserves a status row. The shipped viewer uses the full screen height for Page Up/Page Down and this value to recover the terminal height used by H/L.

Menu.openUrl(url)

Open a detected plain-text URL with the configured local URL handler. Returns whether it opened. This menu-only operation carries local browser authority and is unavailable to connected scripts.

Menu.openHyperlink(id)

Open an OSC 8 hyperlink from the conio hyperlink registry. Returns whether it opened. The shipped viewer copies the URL to the clipboard when opening fails.

Menu.setHyperlinkHover(id)

Set the offline viewer’s pointer and status-row URL for a hyperlink ID; pass zero to clear both. This is a presentation operation for the trusted menu viewer, not a connected-session capability.

Menu.applicationTitle

Return the classic header text, combining the SyncTERM version and active conio output-backend description. This is display data and does not change the OS window title.

Menu.timeText

Return the current local time in the classic 24-character Fri Jul 17 2026 07:30 pm form used by the offline title row.

Menu.showEntry(entry)

Set the OS window title to the classic name/call-count/last-connected summary for a current menu BBS. Passing null restores the bare SyncTERM version title. The purpose-specific operation returns true; stale, default, and non-BBS values abort. It is menu-only so connected scripts do not gain arbitrary application-title authority.

Menu.statusMessage(status)

Return the host diagnostic String for a MenuReadStatus value.

Menu.quitApplication()

Set SyncTERM’s process exit latch after trusted menu code has completed any required work. This is available for explicit menu commands; OS window-close requests set the same latch in the host.

Menu.entries

Return a new List of host-created menu BBS handles in current sort order. The records themselves remain C-owned.

Menu.canAppendEntry

Return whether the combined personal and read-only directory has room for another personal entry.

Menu.defaults

Return the mutable root-section defaults record, or null before a successful load. Its save() writes default fields rather than a named BBS section.

Menu.nameAvailable(name)

Test whether a new personal entry name is valid and unused by another personal entry. Names are 1..30 bytes and the reserved syncterm-system-cache name is rejected. A personal entry may intentionally shadow a read-only system entry of the same name.

Menu.create(name)

Create an unsaved personal entry from Menu.defaults, reset its statistics, sort the model, and return its BBS handle. Returns null for an invalid/duplicate name or allocation failure.

Menu.copy(source, name)

Create an unsaved personal copy of source, with reset statistics and the supplied name. source may be a current directory record or a valid transient produced by quickConnect or the host command-line save flow. Returns null on failure.

Menu.sort()

Reapply the configured BBS-list sort profiles.

Menu.sortFields

Return fresh [fieldId, displayName, defaultReversed] rows from the host sort-field registry. Field IDs are positive. The Boolean says that the field’s normal profile direction is descending.

Menu.sortProfiles

Return fresh [name, order] rows. order is a List of unique signed field IDs and may be empty; negating an ID toggles that field relative to its defaultReversed direction.

Menu.activeSortProfile

Zero-based index of the active profile.

Menu.setActiveSortProfile(index)

Select a profile in memory, apply its order, and re-sort the loaded directory. Returns false for an invalid index.

Menu.addSortProfile(index, name, order)

Insert a profile. Names are 1..19 bytes and case-insensitively unique. Returns false for invalid data or allocation failure.

Menu.updateSortProfile(index, name, order)

Replace a profile’s name and field order. Updating the active profile also re-sorts the loaded directory. Returns false when validation fails.

Menu.deleteSortProfile(index)

Delete a profile and re-sort with the resulting active profile. Deleting the last profile restores the built-in profiles. Returns false on failure.

Menu.saveSortProfiles()

Write the working profiles, active profile, and current order to syncterm.ini. Returns Bool; safe mode treats the write as a no-op.

The shipped offline viewer saves the menu screen, calls prepareOfflineScrollback(), captures the resulting background, and restores the saved mode, fonts, palette, video flags, cursor, mouse subscriptions, and screen when it closes. It starts at the newest screen. Up/J and Down/K move one line, Page Up/Page Down move by the physical screen height, H/L move by the terminal content height, the mouse wheel moves one line, F1 opens the Markdown help, and Escape returns to the directory. Clicking opens OSC 8 or detected plain-text URLs, hovering shows an OSC 8 URL in the status row, and button-one drag invokes SyncTERM’s line-mode text selection.

MenuReadStatus constants are ok, passwordRequired, decryptFailed, readFailed, migrationListOpenFailed, migrationIniOpenFailed, migrationIniReadFailed, migrationListWriteFailed, and migrationIniWriteFailed.

Menu BBS properties map directly to the real struct bbslist:

Group Properties

Identity and state

Read-only name, type, id, added, connected, calls, connTypeName, and dirty; methods rename(name), save(), delete(), and toString.

Address and authentication

Read/write addr, port, connType, user, password, syspass, addressFamily, sftpPublicKey, sshAllowAes128Cbc, sshAcceptEarlyData, and binary String sshFingerprint. The fingerprint is empty, 20-byte SHA-1, or 32-byte SHA-256 data.

Display and emulation

Read/write music, rip, comment, termName, screenMode, bpsRate, font, noStatus, hidePopups, yellowIsYellow, forceLcf, palette, and lfExpand.

Paths and logging

Read/write dlDir, ulDir, logFile, appendLogFile, xferLogLevel, and telnetLogLevel.

Serial and protocol

Read/write stopBits, dataBits, parity, flowControl, telnetNoBinary, deferTelnetNegotiation, ghostProgram, and sortOrder.

Setters mutate only the in-memory record and set dirty; save() is the persistence boundary and returns Bool. A rename is likewise in-memory until save() performs the section rename and field update together. delete() removes a personal entry from disk before removing it from the model. System and web-cache records are read-only: setters abort, while rename, save, and delete return false. Pass one to Menu.copy to obtain an editable personal copy.

The shipped entry editor invokes those setters, including rename(), after each nested field editor accepts a changed value. It calls save() for a dirty entry when the entry editor closes. The fingerprint has no direct editor row; the shipped F5/F6 clipboard carries it through the draft so copying an entry preserves an accepted SSH host key.

Download Path and Upload Path remain direct entry rows. Log Configuration opens the four intended logging controls: Log Filename, File Transfer Log Level, Telnet Command Log Level, and Append Log File. Accepted values still update the working BBS before the logging submenu closes.

A successful full load, create, or delete advances the model generation. Every previously obtained menu BBS handle then becomes stale; using one aborts with an instruction to reacquire it from Menu.entries. This makes use-after-delete and references into a transactionally replaced list deterministic. Sorting and in-place renaming preserve handles.

Menu.settings returns a C-owned Settings snapshot. Property setters validate the assigned value, change only that snapshot, and set dirty. apply() applies the snapshot to the running program without writing syncterm.ini. Values copied from the running settings are not revalidated, so values accepted by older versions pass through unchanged. reload() discards changes not yet applied and repopulates the same handle from the current running settings.

dirty specifically means that the snapshot differs from the running settings. A successful apply() clears it. It does not indicate whether those running settings have subsequently been written to syncterm.ini; code which separates apply() from save() must track that persistence obligation itself.

save() preserves unrelated INI sections, writes the existing syncterm.ini keys, and applies the settings which have immediate effects. These include scrollback capacity, scaling, mouse-wheel direction, audio backends, custom-mode dimensions, cursor style, and the Classic Theme colours used when the built-in theme is active. It returns false when allocation, encrypted-list rewriting, or INI persistence fails. An unapplied snapshot is not applied after such a failure; settings already accepted through apply() remain active. Changing the KDF work factor may rewrite an encrypted BBS list during apply(). Saving syncterm.ini is disabled in safe mode.

A successful apply() or save() advances the settings generation. The object which performed the operation remains valid and clean, but any other outstanding Settings handles become stale and abort when used. Reacquire a snapshot from Menu.settings after another component applies or saves settings.

The shipped Program Settings editor calls apply() after each accepted setting and calls save() once when the editor closes. Thus settings with immediate effects change at field-confirmation time while the INI write remains grouped at the Program Settings screen-exit boundary.

The editor keeps Classic Theme Colours and Custom Screen Mode as nested groups instead of flattening their fields into Program Settings. A connected invocation omits Custom Screen Mode. The connected settings list also omits Current Screen Mode and List Encryption; List Encryption is omitted in every mode when Menu.encryptionAvailable is false.

Property Meaning

confirmClose, promptSave, invertWheel

Bool program options.

modemDevice, modemInit, modemDial, shellTerm

Modem strings and the terminal name exported to local shell connections. The shipped editor accepts 1024 bytes for the device, 1023 bytes for either modem command, and 30 bytes for shellTerm, matching the intended controls and their backing fields.

listPath

Stored personal-list path or URI. The host resolves it to the active local or cached list path after a successful apply or save. The shipped editor limits it to Menu.maxPathLength.

startupMode, outputMode, cursorStyle, audioModes, scalingMode

Catalog-backed display, backend, and audio selections. audioModes is a bit field, so the values in Menu.audioModes may be combined.

scrollbackLines, modemRate

Scrollback capacity is 1 through INT_MAX. Modem DTE rate is 0 through UINT32_MAX; zero means the current or default communications-port rate.

kdfShift

The exponent in the persisted scrypt-Nn key-derivation setting; accepted range is 8 through 24. Changing it rewrites an encrypted personal list using the current password when the setting is applied. An older persisted KDF specification is preserved when another setting changes; assigning kdfShift replaces it with scrypt-Nn.

customRows, customColumns, customFontHeight, customAspectWidth, customAspectHeight

Custom text-mode geometry. Rows are 14..255, columns are 40..255, font height is exactly 8, 14, or 16, and both aspect values are 1 through INT_MAX.

frameColor, textColor, backgroundColor, inverseColor, lightbarColor, lightbarBackgroundColor

Palette indexes used to construct the built-in Classic Theme. Frame, text, and lightbar foreground use Menu.colors, whose Default entry is index 16. Background, inverse, and lightbar background use Menu.backgroundColors, whose Default entry is index 8. The C theme loader resolves those sentinels. File themes are independent sparse overlays on the compiled default.

The six values are persisted under [ClassicTheme]. On load, each missing key falls back independently to the same key under the former [UIFC] section, so a partial new section does not discard old preferences. The next successful Program Settings save writes all six [ClassicTheme] values and removes [UIFC]. A failed save leaves the existing file unchanged.

The host supplies display catalogs rather than requiring scripts to duplicate platform-dependent enum tables:

Menu member Result

screenModes, cursorStyles, scalingModes

Lists of display names indexed by the corresponding numeric setting.

connectionTypes, addressFamilies, rates, musicModes, ripModes, flowControls, parities, fontsCatalog, logLevels

Lists of [value, displayName] rows for directory-entry editors. fontsCatalog uses the loaded conio font index as value; a BBS’s font property remains the display-name String.

defaultPort(connectionType)

Return the built-in default port for a connection-type value.

serialRates(device)

Open the named serial device and return its supported rates as [value, displayName] rows, including Current as rate zero. If the device cannot be queried, return the normal rates catalog.

flowControlsNoRts

Return the [value, displayName] flow-control rows valid for a 3-wire serial connection: XON/XOFF and None. The shipped editor does not replace an existing RTS/CTS value unless the user accepts a new choice.

paletteDefaults(screenMode)

Return [minimumColorCount, colors] for a screen mode. colors is a 16-element List of mode-specific 24-bit RGB defaults; the minimum is 16 for normal modes, 8 for Prestel, and 4 or 2 for the corresponding Atari palettes. Returns null when the mode cannot be mapped.

outputModes

Build-dependent List of [value, displayName] rows. Assign only a value present in this list to Settings.outputMode.

audioModes

Build-dependent List of [bit, displayName] rows for selectable audio backends.

buildOptions

List of [category, displayName, enabled] rows describing this executable’s crypto, terminal, video, and audio build features.

maxPathLength

Build-specific MAX_PATH value used by menu-backed path fields.

encryptionAvailable

Bool indicating whether this build exposes personal-list encryption.

colors, backgroundColors

Foreground and background color-name Lists, including their final Default entries.

currentScreenMode

Current SyncTERM screen-mode enum value.

setScreenMode(mode)

Apply a concrete screen mode immediately and restore the configured default cursor. ScreenMode.current is not accepted. Returns true after dispatching the mode change.

fileLocations

Map containing globalList, personalList, configuration, download, cache, keys, scripts, and themes paths for display. These Strings do not confer file-open authority.

Theme storage and file syntax are documented in Themes.adoc.

Menu.themes refreshes the C-owned catalog and returns rows in the form [filename, name, author, description, version, error]. The built-in Classic Theme has an empty filename. Optional metadata is null when absent. An invalid or missing theme has an error String and cannot be selected, but remains available for display in the browser.

Menu.selectedThemeFile returns the committed filename, or an empty String for Classic Theme. Menu.previewTheme(filename) installs a temporary process-wide preview and returns null, or returns an error String without changing the committed selection. Menu.cancelThemePreview() restores the committed theme. Both operations advance Host.themeGeneration when they change the active snapshot.

Menu.selectTheme(filename) validates the catalog entry, writes [SyncTERM] ThemeFile, and commits the preview as one operation. Selecting Classic removes the key. It returns null on success or an error String on failure. In safe mode the running selection changes but the INI file is not written. A successful selection advances both the theme and settings generations, so scripts should reacquire outstanding Settings snapshots.

Menu.encryptionAlgorithm, Menu.encryptionKeySize, and Menu.encryptionName describe the personal BBS list currently in memory. MenuEncryption defines none, aes, and chacha20.

Menu.setEncryption(algorithm, keySize, password) rewrites the current personal list and returns Bool. AES accepts 128- or 256-bit keys; ChaCha20 and unencrypted lists require key size zero. password may be null to reuse the password already held by the host. Enabling encryption fails if neither source supplies a non-empty password. A successful rewrite updates the current algorithm, key size, and held password; a failure leaves them unchanged. The operation is disabled in safe mode.

Menu.webLists returns a fresh List of [name, uri] rows. Names must be unique and non-empty; System List is reserved. Indexes refer to the order shown by that returned List. Menu.webListsDirty reports whether the in-memory list has unpersisted edits.

Method Description

addWebList(name, uri, index)

Fetch the URI into the web-list cache and insert the in-memory entry. Returns null on success or an error String.

updateWebList(index, uri)

Change an in-memory entry’s URI. Returns Bool. It does not refresh the cached list.

deleteWebList(index)

Remove an in-memory entry. Returns Bool.

saveWebLists()

Persist the in-memory entries to the WebLists INI section. Returns Bool. A failed write leaves webListsDirty set so it can be retried.

refreshWebList(index)

Fetch the current URI into the cache without changing configuration. Returns null on success or an error String.

In safe mode, add, update, and delete still update the running configuration, and add still performs its initial fetch. saveWebLists() returns false without writing syncterm.ini, so those changes last only for the current process. Refresh remains permitted. No web-list method exposes its cache pathname as a file capability.

Custom-font configuration is held in a C-owned model separate from the loaded conio font slots. Menu.fonts returns a fresh List of MenuFont handles, or null if the INI file could not be read. Menu.fontsDirty reports whether that model has unsaved edits.

Method Description

Menu.canCreateFont

Return whether another custom-font record can be added. This includes the build’s available conio slots, safe mode, and model-read status.

Menu.createFont(name, index)

Insert a blank custom font at index and return its handle. The name contains 1..50 bytes. As with the native Font Management screen, this operation does not impose uniqueness or character restrictions. Returns null for an empty or overlong name, allocation failure, safe mode, or when all available custom conio slots are represented.

Menu.saveFonts()

Replace the Font: sections in syncterm.ini, then clear and reload conio’s custom font slots. Returns Bool. A persistence failure leaves the previously loaded slots in place.

Menu.reloadFonts()

Discard unsaved model edits and reread the INI file transactionally. Returns Bool.

MenuFont.name

Read/write display and INI-section name, containing at most 50 bytes. The native editor permits an empty or duplicate replacement name, so the binding does as well.

MenuFont.path(slot)

Configured pathname String for display, or null. Returning a pathname does not mint a File and confers no open authority.

MenuFont.setFile(slot, file)

Assign a path from a live, read-authorized File, normally returned by Host.pickFile. Returns Bool. The picker mask guides selection; the binding does not add a file-size restriction absent from the native Font Management screen.

MenuFont.clearFile(slot)

Clear one configured size and return Bool.

MenuFont.delete()

Remove the record from the in-memory model and return Bool. Call Menu.saveFonts() to persist the deletion.

MenuFontSlot values and their picker masks are:

Constant Code Bytes Typical mask

eightByEight

0

2048

*.f8

eightByFourteen

1

3584

*.f14

eightBySixteen

2

4096

*.f16

twelveByTwenty

3

10240

*.f20

Create and delete advance the font-model generation, invalidating all previously obtained MenuFont handles. Rename, file assignment, clear, and save preserve handles. Font mutation and persistence are disabled in safe mode; reload remains available because it only reads the existing configuration.

The menu BBS properties use the following numeric constants. Most are shared with the active-connection BBS API, but the catalogs returned by Menu should be preferred when constructing a user-facing choice list.

ConnType

Constant Code Description

unknown

0

Unset / not yet decided.

rlogin

1

RLogin (TCP/513) — classic BBS auth-on-connect protocol.

rloginReversed

2

RLogin with the handshake byte order reversed (some servers).

telnet

3

Telnet (TCP/23) — IAC negotiation in NVT mode.

raw

4

Raw TCP — no protocol layer; bytes pass straight through.

ssh

5

SSH-2 — encrypted with password / key authentication.

sshNoAuth

6

SSH-2 with the "none" authentication method (anonymous).

modem

7

Phone modem dial-out via AT commands.

serial

8

Direct serial port.

serialNoRts

9

Serial without RTS/CTS hardware flow control.

shell

10

Local subprocess (shell command).

mbbsGhost

11

MajorBBS / Worldgroup "GHost" client interface.

telnets

12

Telnet over TLS (TCP/992).

Emulation

Constant Code Description

ansiBbs

0

ANSI-BBS — ANSI X3.64 / VT-style escape sequences with PC-style color attributes. The default.

petascii

1

Commodore 64 / 128 PETSCII control codes.

atascii

2

Atari 8-bit ATASCII control codes.

prestel

3

UK Prestel / Viewdata teletext — block mosaic graphics, double-height, conceal.

beeb

4

BBC Micro Mode 7 teletext.

atariVt52

5

Atari ST GEM VT-52 emulation.

BBSListType

Constant Code Description

user

0

User-edited entry from the user’s BBS list.

system

1

System-shipped entry from the SyncTERM-bundled list. Read-only at the UI level.

ScreenMode

Standard modes encode their dimensions in the name (e.g. c80x25 = 80 columns × 25 rows). Codes are stable enum values.

Constant Code Description

current

0

"Don’t change" — keep the current mode.

c80x25

1

80×25 (CGA / VGA standard).

lcd80x25

2

80×25 with LCD-style cell aspect (SyncTERM-specific).

c80x28

3

80×28 (extended VGA).

c80x30

4

80×30 (extended VGA).

c80x43

5

80×43 (EGA-extended).

c80x50

6

80×50 (VGA 8-pixel-tall cell).

c80x60

7

80×60 (extended VGA).

c132x37

8

132×37 (Super VGA).

c132x52

9

132×52 (Super VGA).

c132x25

10

132×25.

c132x28

11

132×28.

c132x30

12

132×30.

c132x34

13

132×34.

c132x43

14

132×43.

c132x50

15

132×50.

c132x60

16

132×60.

c64

17

Commodore 64 (40×25 PETSCII).

c128_40

18

Commodore 128 (40-column mode).

c128_80

19

Commodore 128 (80-column mode).

atari

20

Atari 8-bit native (40×24 ATASCII).

atariXep80

21

Atari 8-bit with XEP80 80-column expansion box.

custom

22

Custom dimensions configured per-BBS.

ega80x25

23

EGA 80×25 with hardware-accurate pixel aspect (8×14 cell).

vga80x25

24

VGA 80×25 with hardware-accurate pixel aspect (8×16 cell).

prestel

25

Prestel teletext (40×24).

beeb

26

BBC Micro Mode 7 (40×25 teletext).

atariSt40x25

27

Atari ST 40×25 (low-res color).

atariSt80x25

28

Atari ST 80×25 (medium-res color).

atariSt80x25Mono

29

Atari ST 80×25 monochrome (high-res).

AddressFamily

Constant Code Description

unspec

0

Don’t care — let the resolver pick whichever family the host has.

inet

1

IPv4 only.

inet6

2

IPv6 only.

MusicMode

Controls how SyncTERM interprets ANSI music sequences (CSI …​ character).

Constant Code Description

syncterm

0

SyncTERM’s strict mode — only the CSI MNML sequence triggers music; less-anchored sequences are passed through as text. The default.

bansi

1

BANSI-style — older BBS music parsing rules.

enabled

2

Most permissive — all forms accepted.

RipVersion

Constant Code Description

none

0

RIP support disabled for this connection.

v1

1

RIPscrip v1.54 compatibility mode.

v3

2

SyncTERM’s "idealized" RIP v3 — bug-fixed and extended; not bug-compatible with v1.54.

Parity

Constant Code Description

none

0

No parity bit (8N1 framing).

even

1

Even parity bit.

odd

2

Odd parity bit.

FlowControl

Read-only foreign class — instance returned from BBS.flowControl.

Member Description

rtsCts

Bool — RTS/CTS hardware flow control enabled.

xonOff

Bool — XON/XOFF software flow control enabled.

LogLevel

Standard syslog severity values (RFC 5424). emergency is the most severe; debug is the least.

Constant Code Description

emergency

0

System is unusable.

alert

1

Action must be taken immediately.

critical

2

Critical condition.

error

3

Error condition.

warning

4

Warning condition.

notice

5

Normal but significant.

info

6

Informational.

debug

7

Debug-level diagnostic.

Host

Module-private bridge for state the host owns but Wren shouldn’t be able to construct directly. The members below are foreigns; the Cache / Download module-level vars (see below) are pre-bound from these at module load.

Member Description

Host.themeGeneration

Monotonic process-wide generation for the active theme. Preview, cancellation, and committed selection changes advance it.

Host.themeData, Host.defaultThemeData

Return fresh immutable-by-ownership snapshots of the active theme and compiled fallback respectively. Each result is [styles, glyphs]. A style row is [role, font, legacyAttr, foreground, background], with null representing inheritance. A glyph row is [name, primary, asciiFallback]. These bindings are available in all three VMs. Wren may mutate its returned Lists, but that cannot change C-owned theme state or snapshots returned to another VM.

Host.cacheDirectory

Returns a fresh Directory whose path resolves lazily from the active BBS context (SYNCTERM_PATH_CACHE). No Wren-visible constructor for an is_cache Directory exists, so this is the only path to one. Use the module-level Cache binding instead in most code.

Host.downloadDir

Returns a fresh Directory rooted at the BBS’s configured DownloadPath, with the relaxed-name predicate enabled (1..255 bytes; no path separators / NUL / control bytes / . / .. / Windows reserved devices, but spaces / leading dots / parens etc. allowed). Returns null when DownloadPath is empty, equal to the user’s $HOME, or doesn’t exist as a directory on disk — consumer code uniformly null-checks Download before use, so routing the configured-but-missing case through the same null gate keeps every call site simple. Used as the module-level Download binding.

Host.uploadPath

The BBS’s configured UploadPath as a String, or null when unconfigured. Pass to pickFile / pickFiles as initialDir. No upload-side Directory is exposed on purpose — uploads must go through the picker (which mints a per-file consent token); scripts can’t enumerate UploadPath or open files from it directly.

Host.pickFile(initialPath, mask, opts)

User-consent escape hatch for "upload from anywhere": invokes the isolated picker VM and returns a File whose path is the absolute path the user picked, or null on cancel. initialPath may be a String path, a Directory foreign (uses its path; handles Cache lazy-resolve), or null (defaults to BBS.ulDir). An existing file path opens its containing directory with that file selected. mask may be null (defaults to *). opts combines the stable FilePickerOptions values documented below. The returned File’s path bypasses the relaxed-name policy; the picker interaction is the local consent boundary, since the user explicitly chose the path. The returned `File is read-only and carries a .token consent token (see "Picker consent tokens" below) when the per-installation signing key is loaded.

Host.pickFile(initialPath, mask, opts, title)

The same consent picker with a caller-supplied title String. The title changes only the picker presentation and grants no additional file authority.

Host.pickFiles(initialDir, mask, opts)

Multi-select counterpart of pickFile. Same argument shape. Returns a non-empty List<File> on OK (each File is read-only and has a .token), or null on cancel / empty selection. allowEntry, confirmOverwrite, and confirmCreate cannot be combined with multi-select.

Host.pickSavePath(initialDir, mask)

Save-mode picker with path entry and overwrite confirmation. The user can pick an existing file or type a new filename. The picker reports create or overwrite consent explicitly, and the caller does not stat the path again. Returns a write-consent File whose .open() succeeds exactly once, or null on cancel. See Write consent below for the mode rules and single-shot semantics. .token on the returned File is null — write consent is intentionally session-bound and doesn’t replay across sessions.

Host.openLocalFile(token)

Re-construct a File foreign from a consent token previously returned by pickFile / pickFiles via the .token getter. Returns null when the token is invalid (HMAC mismatch, signing key rotated), the file no longer exists, or the file’s content has changed since consent (SHA-256 mismatch). The reconstructed File is read-only. This is the only path by which Wren can construct a File for a path outside the sandboxed Cache / Download roots; the token is the consent.

Host.uploadArrow, Host.uploadArrow = b, Host.downloadArrow, Host.downloadArrow = b

Status-bar transfer-indicator arrows (CP437 ↑ / ↓, painted by the default Status render at the row’s right edge). Generic — any Wren script that knows it’s transferring something can light them; the canonical user is the SFTP queue’s worker fibers, but a Zmodem / HTTP fetcher / etc. could too. Pure Wren-side state on the Host class: both default to false and vanish with the VM at teardown, no C-side reset needed. The setter calls CTerm.refreshStatus() for you so the bar repaints on the next pass without writers having to remember.

Host.safeMode

true when SyncTERM was started with -s (safe mode): no auto-connect, no SFTP key writes, no script-driven external commands. The default status bar surfaces (SAFE) when this is set so the user can see what mode they’re in.

Host.textTerminal

true when the active ciolib backend is a text-mode terminal (curses / curses-IBM / curses-ASCII / ANSI) rather than a graphical / windowed mode (X / SDL / Wayland / Quartz / GDI). Stable for the lifetime of the session, so typically queried at module-load time to gate hook registration — e.g. the default Ctrl-Q hangup hook is only installed when this is true, because graphical backends pass Ctrl-Q through to cterm as a normal control byte.

Host.altKeyName, Host.altKeyShort

Display name of the modifier key that produces Alt keycodes on this platform. altKeyName is the long form ("Alt" everywhere except macOS, where it’s "Command" because the Quartz backend maps Cmd to Alt and reserves Option for input composition); altKeyShort is the matching three-letter abbreviation ("ALT" / "CMD", both 3 chars wide regardless of platform). Use the long form in inline help / menu entries and the short form in tight slots like the status bar so menus stay 40-column-safe on Mac.

Host.print(s)

Write s and a newline to standard output, then flush. Distinct from System.print(s), which is captured by the Wren console log buffer (see Wren Console) and never reaches the process’s actual stdout. Host.print bypasses the log and emits to the launching shell — intended for scripts run under syncterm -W that need to report results / progress lines to a test harness or other launching process.

Host.launchScript

Full path of the Wren script supplied via the -W command-line flag, or null when -W wasn’t used. Lets a script that is also embedded for normal Alt+key dispatch detect command-line invocation and run itself immediately: + [source,wren] ---- if (Host.launchScript != null && Host.launchScript.endsWith("foo.wren")) { Foo.run() } ----

Host.logUnread, Host.logUnreadError

Wren-console activity since the user last visited the REPL. logUnread is true when any non-empty append has happened (script print output, hook metrics, etc.); logUnreadError is the subset for compile / runtime errors and stack frames. The default connected status bar and the main menu’s top-right corner paint a CP437 indicator — red when logUnreadError is set, yellow when only logUnread is set, blank otherwise. Each VM has independent counters. Cleared by visiting that VM’s console pane.

Host.musicNames, Host.musicHelp

ANSI music mode display strings and help blurb, returned as a fresh List<String> indexed by MusicMode and a multi-line String respectively. Same source as the bbslist editor’s music picker (music_names[] and music_helpbuf in bbslist.c), so a Wren-side picker stays aligned with the C-side dialog.

Host.outputRates, Host.outputRateNames

Throttled-output ladder. outputRates[i] is the BPS value (a Num); outputRateNames[i] is the human label (e.g. "115200" for index 10, "Current" for the trailing 0 = no-cap entry). Indexes line up between the two lists. Pass any rate value to CTerm.throttleSpeed = bps. The online_menu Output Rate picker uses these to populate its sub-list.

Host.logLevel, Host.logLevel = n, Host.logLevelNames

Transfer log threshold (0 = Emergency, …​, 7 = Debug) and the matching display labels indexed by the same number. Out-of- range values are clamped on write. The online_menu Log Level picker writes the setter; the transfer dialog reads via the C-side log_level global.

Host.haveOOII

true when the build has Operation Overkill ][ tone support compiled in (i.e. WITHOUT_OOII is not defined). The online_menu consults this to decide whether to expose the "Toggle OOII" entry.

Host.maxOOIIMode

Highest valid OOII mode value (returns 0 in WITHOUT_OOII builds). The online_menu wraps the OOII increment back to 0 when it exceeds this cap, so the mode cycle is 0 through Host.maxOOIIMode.

Host.editBBSList()

Suspend terminal byte consumption and enter the persistent trusted menu VM over a saved copy of the connected screen. The connected VM is parked, no Wren or foreign object crosses between VMs, and trusted input barriers discard queued synthetic keyboard and mouse input in both directions. On return, the host restores the terminal screen, title, scrollback configuration, and prior suspension state. This is the default Alt+E path.

Host.sshPublicKey

The first locally-configured SSH public key, as a Map {"algo": String, "blob": String} (e.g. algo: "ssh-ed25519", blob: "AAAA…​"), or null when none is available (no SSH backend, key not loaded, OOM). Used by scripts/auto/connected/sftp_pubkey.wren to append the user’s authorized_keys entry on connect when BBS.sftpPublicKey is set. Today only ed25519 is exposed; the structured shape lets future RSA / sntrup keys join without breaking consumers.

FilePickerOptions

Public option bits for Host.pickFile and Host.pickFiles. The numeric assignments are stable so scripts may combine or persist them.

Member Value Meaning

none

0

No optional behavior.

maskLocked

1

Display the mask but do not let the user edit or replace it.

maskCaseSensitive

2

Match file and directory masks case-sensitively.

caseSensitiveSort

4

Sort entry names case-sensitively.

selectDirectory

8

Select a directory rather than a file.

pathMustExist

16

Reject a typed path which does not exist.

fileMustExist

32

Reject a typed filename which does not exist.

showHidden

64

Include hidden entries in directory listings.

allowEntry

256

Allow the always-visible Path field to receive focus and accept a typed directory or filename.

confirmOverwrite

512

Ask before accepting an existing destination.

confirmCreate

1024

Ask before accepting a destination which does not exist.

allowEntry, confirmOverwrite, and confirmCreate are invalid with Host.pickFiles. Host.pickSavePath supplies its save-mode options itself.

Cache

Module-level Directory injected from C (pre-bound from Host.cacheDirectory), pointing at SyncTERM’s per-user cache directory (SYNCTERM_PATH_CACHE). Use this for script-private state that should survive restarts.

Cache.list returns a Map keyed by entry name; values are File objects for regular files and Directory objects for subdirectories. That’s the read-side handle factory — a script reads existing cache content by indexing into Cache.list, and creates new content with Cache.create(name):

import "syncterm" for Cache

// Read an existing cache file.  Indexing the list returns the
// File object directly, no separate "open by name" step.
if (Cache.contains("greetings.txt")) {
  var f = Cache.list["greetings.txt"]
  f.open()
  System.print(f.read())
  f.close()
} else {
  // First run: create + write.  create() returns null if the file
  // already exists or the OS rejects the create.
  var f = Cache.create("greetings.txt")
  if (f != null) {
    f.open()
    f.write("Hello, world!")
    f.close()
  }
}

// Subdirectories appear in the same map.  Walk into them by chaining
// `.list` lookups:
//   Cache.list["RIP"]                         → Directory for /RIP
//   Cache.list["RIP"].list["icons.dat"]       → File inside /RIP

There is no Wren-callable factory for DirectoryCache, Download, and Cache.list[someSubdir] (and the analogous Download.list[…​]) are the only paths to one.

Download is a module-level Directory binding pre-bound from Host.downloadDir at import time. It is null when the configured DownloadPath is empty, set to the user’s $HOME, or doesn’t exist as a directory on disk; scripts MUST null-check before use. Its relaxed-name predicate is what makes typical SFTP filenames ("file with spaces.zip", "doc-v1.0.tar.gz") usable as local paths in transfer queues.

There is intentionally no Upload counterpart. All uploads must go through Host.pickFile / Host.pickFiles, which mint a per-file consent token tied to the file’s content hash. The script then enqueues uploads by token (File.token); the worker re-opens the source via Host.openLocalFile(token). Use Host.uploadPath to feed the configured UploadPath to the picker as initialDir.

Directory

Opaque handle pointing at a directory on disk.

Method Description

Directory.contains(name)

true if a file named name exists in the directory.

Directory.list

A Map keyed by entry name; values are File objects for regular files and Directory objects for subdirectories. Symlinks, device nodes, FIFOs, etc. are silently skipped. Names are filtered by the filename policy, so hidden / dotfiles / Windows-reserved names don’t appear. Each read enumerates the directory afresh; cache the value if you’ll touch it more than once. Use this to obtain handles to existing files, or to walk a tree.

Directory.create(name)

Atomically create a new zero-byte file named name in the directory and return a File object for it. Uses C11 exclusive- create mode (fopen(path, "wbx")) so a concurrent creator doesn’t silently truncate an existing file. Returns null if the file already exists, the name violates the filename policy, the path is too long, or the OS rejects the create for any other reason. The file is closed on return — call File.open() before reading or writing.

Directory.createDir(name)

Atomically create a new subdirectory named name and return a Directory object for it. Uses mkdir directly, which fails atomically if the path already exists — same race-free semantics as create() for files. Returns null on the same error conditions: existing entry, invalid name, path too long, or OS rejection (no permission, parent missing, …). Use list[name] to obtain a handle to a subdirectory that already exists.

Directory.delete(name)

Remove the entry named name from the directory. Handles regular files and empty subdirectories; refuses anything else (symlinks, device nodes, FIFOs, non-empty directories). Returns true on successful removal, false on any failure (no such entry, wrong type, non-empty directory, no permission, invalid name, …). On success, every live File / Directory handle whose path is at-or-below the removed entry is marked dead and pulled from the live-handle registry; a subsequent operation on any such handle aborts the calling fiber with a "handle is dead" error.

Handle staleness

File and Directory foreign objects are filesystem snapshots — the C side caches the absolute path at the moment the handle was issued (via Directory.list, Directory.create, Directory.createDir, or Cache). A subsequent Directory.delete invalidates handles to the removed entry by walking a live-handle registry and setting a dead flag on each match.

Two layers of protection keep operations from acting on a stale path:

  1. Active invalidation. Directory.delete walks every live handle and marks dead anything whose path is the removed entry or a descendant of it. Subsequent ops on a dead handle throw.

  2. Per-call existence check. Every File / Directory method re-checks fexist(path) / isdir(path) before doing anything. If the path is missing — because something outside the script (another script, another process, the user) deleted it — the handle is marked dead, removed from the registry, and the operation aborts the calling fiber. This catches deletions that bypassed Directory.delete.

The active layer is fast (one boolean check); the existence layer costs a stat() per operation but covers the externally-modified case. Together they ensure no File / Directory operation ever silently acts on the wrong path.

Open files are exempt

A File handle whose underlying file is currently open (between File.open() and File.close()) is not marked dead by the invalidation walk and bypasses the per-call fexist() check. This matches platform reality:

  • On Unix, an open file descriptor remains valid after the path is unlinked; reads and writes continue to work, and the inode is freed only when the last reference closes.

  • On Windows, deleting a file that’s currently open fails at the OS layer, so the situation never arises.

While the file is open, operations against the fd hit OS-level errors if the underlying file is genuinely gone (very rare on Unix, impossible on Windows). File.close() re-runs the existence check after fclose; if the path is gone at that point, the handle is marked dead and unregistered, and the next operation throws.

Scripts should drop stale handles when they’re done with them; the handles aren’t hazardous (operations throw rather than corrupt state) but holding them prevents the underlying foreign data from being GC’d.

Filename Policy

Filenames passed to any Directory or File method must satisfy:

  • Length 1..64 characters.

  • Characters from [A-Za-z0-9._-] only.

  • Must not begin with . or -.

  • Must not end with ..

  • Must not contain ...

  • Must not match a Windows reserved device name (CON, PRN, AUX, NUL, COM1..COM9, LPT1..LPT9), case-insensitively and with or without an extension.

Method calls violating the policy fail (return null / no-op); methods on the resulting File are no-ops if construction failed.

File

Every File carries rights assigned by the host when it is created:

  • Files reached through Cache or Download are read/write.

  • Files returned by Host.pickFile / Host.pickFiles, or reconstructed by Host.openLocalFile, are read-only. open() uses rb; write methods and mtime=() abort the fiber. Read-consuming bindings such as Screen.loadFont() accept these Files.

  • Files returned by Host.pickSavePath are one-shot write-only grants. Read methods, hashes, and read-consuming bindings reject them. See Write consent.

Rights belong to the foreign handle, not the path. Passing a File to another binding does not broaden its authority.

Method Description

File.open()

Open the file using the mode authorized by its host-assigned rights. Sandbox Files use r+b, picker/token Files use rb, and save-picker Files use the one-shot mode described below. Subsequent permitted reads / writes start at offset 0.

File.close()

Close the file handle.

File.readBytes(count)

Read up to count bytes from the current offset; advance. Returns the bytes as a String.

File.readBytes(count, offset)

Read at an absolute offset; do not advance the current offset.

File.read()

Read the entire file from offset 0.

File.writeBytes(s), writeBytes(s, offset)

Write s at the current or given offset.

File.write(s)

Replace file contents with s.

File.readLine()

Read from the current offset to the first LF (0x0A) or EOF and return the bytes read with any trailing LF removed. Advances the offset past the LF on a hit, or to EOF if none was found. Returns null when the offset is already at EOF; a blank line returns the empty string.

File.writeLine(s)

Write s at the current offset, then append an LF (0x0A). The offset advances past the LF. No special handling if s already ends in LF — the trailing LF is appended unconditionally; use writeBytes for raw control over line termination.

File.offset, offset=(o)

Current read/write position.

File.size

Length of the file in bytes.

File.mtime, mtime=(t)

Modification time as POSIX seconds. Setter calls setfdate(2) by path, requires write rights, and works on both open and closed handles. Used by the SFTP queue to stamp completed downloads with the remote’s mtime so the browser’s status chip can compare local-vs-remote without server-side hash extensions.

File.isOpen

true while the handle is valid.

File.token

Opaque consent token for picker-sourced File`s; `null for File`s obtained via `Cache / Download Directory listings. Persist this in WON next to the path string and pass back to Host.openLocalFile on resume to re-construct the File at the same absolute path. See "Picker consent tokens" below.

File.sha1, File.md5

Hashes of the file’s full content; raw digest bytes (20 / 16 bytes) returned as a Wren String. Implementation memory-maps the file; zero-length files are hashed as the empty buffer. Returned as bytes rather than hex so they compare directly against SFTPEntry.hash from the sha1s@syncterm.net / md5s@syncterm.net SFTP extensions; format hex yourself if you need it for display.

A File whose backing file has been deleted via Directory.delete is dead — every subsequent method aborts the calling fiber with "handle is dead" (a programmer error: the script kept the handle after explicitly invalidating it). See the Handle staleness note in the Directory section for the full mechanism.

A File whose backing path vanished externally (another process deleted it, the user rm’d the file outside the script’s awareness) is recoverable — the per-call `fexist() check catches it and returns a FileError with code == FileErr.vanished in place of the typed result. Drop or inspect the error and move on; the fiber is not aborted.

Files minted by Host.pickSavePath carry a write-only consent — a single-shot grant to open the user-picked path exactly once, in a specific mode determined at pick time:

Picker outcome open() mode

Path didn’t exist at pick time

wbx (exclusive create) — fails if the path now exists. Race-safe: an attacker can’t substitute a different file between the picker’s "does it exist?" check and open().

Path existed and the user confirmed overwrite

wb (truncate or create). The picker enforces the prompt; user saying "no" re-prompts.

The consent is consumed by the first successful open(). After the matching close(), every subsequent open() / writeBytes / write / writeLine / etc. aborts the fiber with "write consent already used". Re-pick the path to write again.

The grant never includes read authority. read, readBytes, readLine, sha1, md5, and bindings which consume a readable File reject a save-picker File, both before and while it is open.

File.token is null on write-consent Files: persisting a write consent across sessions would defeat the single-shot rule. The existing token mechanism is exclusively for read consent (the Host.pickFiles / openLocalFile upload-resume flow).

Other bindings consume write consent on the caller’s behalf:

After any of these calls the File is inert — calling open() on it yourself is no longer valid.

FileError

Recoverable I/O failures (disk full, mmap failure, externally-deleted file, OOM mid-op, …) flow through a FileError foreign returned IN PLACE of the typed result. Mirrors SFTPError in shape. Most scripts will simply ignore the value — that’s the point: a failed write returns a FileError the caller can drop on the floor and continue, rather than aborting the fiber. Scripts that care can demux with is FileError.

Member Description

code

FileErr.* enum value (see below). Indicates the failure category.

errno

Captured C errno at the failure point, or 0 when the failure isn’t a libc-call result (e.g., FileErr.oom synthesises ENOMEM when malloc returns NULL).

message

Human-readable diagnostic text, or null.

toString

"FileError: <CODE-NAME>: <message> (errno=<n>)", fields omitted when not present. Useful for logging.

FileErr constants (matching enum file_err_code in wren_bind_fs.c):

Constant Code When emitted

FileErr.ok

0

Sentinel; never appears on a returned FileError.

FileErr.openFailed

1

fopen returned NULL (perms, ENOSPC, missing, …).

FileErr.writeFailed

2

fwrite short-write (disk full, I/O error, write-only mount).

FileErr.statFailed

3

flength / fstat failed mid-op.

FileErr.mmapFailed

4

xpmap returned NULL — only from File.sha1 / File.md5.

FileErr.oom

5

Internal malloc / realloc returned NULL.

FileErr.vanished

6

Backing file/dir gone (race with external rm).

FileErr.resolveFailed

7

Cache-relative path resolution failed.

What is NOT a FileError: programmer errors abort the fiber instead. That covers: using a dead handle (Directory.delete invalidated it), calling read/write on an unopened file, calling open() twice, passing a negative offset/count, reading past EOF, passing the wrong type to a setter. These are all "your code is wrong" — fail loud.

What is also not a FileError: EOF from a short read. File.read and friends return whatever bytes were actually read (possibly empty), not a FileError. The script can detect EOF by comparing the returned byte count to the requested count, just like fread.

Picker consent tokens

Wren scripts must not synthesize File foreigns from arbitrary paths — that’s the whole point of the Cache / Download sandboxed roots and the relaxed-name predicate. The escape hatch is Host.pickFile / Host.pickFiles, where the isolated picker interaction is the user’s signal of consent.

To let queued picker-sourced uploads survive a disconnect, the picker mints a capability token at consent time: an HMAC-SHA256 binding of the absolute path and the file’s SHA-256, signed by a per-installation key stored in the user’s encrypted syncterm.lst. Wren can store the token freely (it’s opaque bytes — exposed via File.token and accepted by Host.openLocalFile); only the C side can mint or verify one. The file-content hash routes through DeuceSSH’s hardware-accelerated digest backend (Intel SHA-NI / ARM crypto extensions, via OpenSSL or Botan) when the build has it, so signing/verifying a multi-GB picked file stays in the sub-second range on commodity hardware. WITHOUT_DEUCESSH builds fall back to xpdev’s pure-C SHA-256 (src/hash/sha256.c) — correct, just slower on huge files; signing key generation in that build seeds from /dev/urandom (or time/PID) via xp_random rather than the SSH backend’s CSPRNG.

At resume time, Host.openLocalFile(token) verifies:

  1. The HMAC matches (token wasn’t tampered with; signing key hasn’t rotated).

  2. The file at the encoded path still exists.

  3. The file’s current SHA-256 still matches the SHA-256 captured at consent time.

Any failure returns null; the queued upload should be marked failed and the user prompted to re-pick. The content hash binding is deliberate: if the file’s content has changed since consent, the original consent doesn’t authorize uploading the new content.

The signing key is loaded only from the user’s personal BBS list (USER_BBSLIST) — never from shared/system or web-fetched lists. This stops a malicious shared list from injecting a known key and forging tokens against the local install. Key rotation is manual: remove the WrenPickerHmacKey line from syncterm.lst; SyncTERM regenerates on next launch. All in-flight picker tokens become invalid.

Host.pickFile / Host.pickFiles continue to work even when the signing key is unavailable (e.g. an unencrypted list, OOM during generation) — the returned File`s have `null .token, so picker uploads in such sessions don’t persist across reconnects but otherwise behave normally.

Hook

The dispatcher registry. See Hook Events for the contract on each method, and HookHandle for the return type.

Hook.onKey(fn), Hook.onPhysicalKey(fn), Hook.onInput(fn), Hook.onMouse(fn), Hook.every(ms, fn), plus the filtered variants Hook.onKey(key, fn), Hook.onInput(byte, fn), Hook.onPhysicalKey(evdev, fn), Hook.onMouse(event, fn), and Hook.onMatch(pattern, fn). Each call returns a HookHandle.

Status

Wren-driven status bar. The host invokes a single render callable on every status-bar update with a width×1 Surface that has been pre-filled to the default attribute (yellow on blue, spaces). The callable mutates cells in place and returns; its return value is ignored.

import "syncterm" for Status, Surface, BBS, CTerm

Status.callable = Fn.new { |surf|
  if (!Status.enabled) return
  var s = "%(BBS.name) | %(BBS.connTypeName) | %(BBS.bpsRate) bps"
  var bytes = s.bytes
  for (i in 0...bytes.count) {
    if (i + 1 >= surf.width) break
    surf[i + 1].chByte = bytes[i]
  }
}

The default implementation lives at scripts/auto/connected/status_default.wren. It uses the layout name (flags) │ ConnType │ speed │ HH:MM:SS │ ALT-Z menu with right-pinned indicators for mouse mode (M) and SFTP transfers (arrows). To override it, place a same-named file in the auto-load directory, or set Status.callable = …​ from any later script. The host releases the previous handle when the callable changes.

Member Description

Status.callable

The currently installed render Fn (or null when nothing has been set). Read it back if you want to chain — wrap the existing callable in your own Fn.new { |s| original.call(s); …​ }.

Status.callable = fn

Install (or clear) the render callable. fn must be a callable taking one argument (the Surface the host hands it); pass null to detach. Releases the previous handle.

Status.enabled

true while the SyncTERM-managed status row is active; false when DECSSDT or startup config has hidden it. The render callable should early-return on !Status.enabled so it doesn’t do layout work that won’t be displayed.

The recycled Surface is reallocated only when the terminal width changes, so per-frame allocation churn stays out of the hot path. Out-of-bounds writes (surf[i] for i >= surf.width) throw — treat surf.width as authoritative and clip your own loops.

Transfer

Mailbox bridge between SyncTERM’s transfer-window UI (Wren-side) and the C protocol drivers running on a worker thread. beginSession spawns the worker; the Wren-side TransferApp then drains log events, snapshots tick state, and watches the done flag in a tick loop until the transfer completes or the user aborts.

import "syncterm" for Transfer

Transfer.beginSession("fake")        // start worker; throws if active
while (!Transfer.done) {
  for (entry in Transfer.drainLog()) System.print(entry[1])
  if (Transfer.tickDirty) {
    var snap = Transfer.snapshot()   // 13-element fixed-shape List
    System.print("bytes: %(snap[1])")
  }
  // ... handle Esc → Transfer.requestAbort() ...
}
Transfer.endSession()

The Stage 2 build only wires "fake" (a 100-KiB synthetic stream that ticks every 100 ms with periodic log messages, useful for exercising the UI path). Real protocol kinds ("zmodem-recv", "ymodem-send", "xmodem-recv", "cet-recv") land in Stage 4.

Member Description

Transfer.beginSession(kind)

Spawn the worker thread and reset the mailbox. kind is a string selecting the protocol. Throws if a session is already active or the kind is unknown. Returns true on success.

Transfer.endSession()

Latch an abort if the worker hasn’t already finished, join the thread, and clear session state. Idempotent — safe to call after a natural completion. Returns null.

Transfer.drainLog()

Drain the log ring under mutex; return a List of [level, text] entries. When the ring overflowed since the last drain, a synthesized [LOG_NOTICE, "[N log lines suppressed]"] marker is appended at the tail so the suppression is visible to the user. Empty list when no events pending.

Transfer.tickDirty

Atomic read+clear of the "tick state changed" flag. Returns true if a tick update has happened since the previous call; callers should follow with snapshot() only on a true return so the mutex is taken at most once per dirty frame.

Transfer.snapshot()

Return a 13-element fixed-shape List with the latest tick state (filename, bytesCur, elapsed, cps, fileIndex, fileTotal, bytesStart, bytesTotal, blockNum, blockSize, blockBits, frameNum, frameTotal). Optional fields are null when the protocol doesn’t populate them (e.g. CET’s frame-count-unknown 999 sentinel collapses to null). Wrap in TickStateView for named-field access (see transfer_app.wren).

Transfer.done

true when the worker has completed (success or abort). The pump loop exits on this flag.

Transfer.success

true when the worker finished without aborting. Read after done flips.

Transfer.requestAbort()

Latch an abort flag the worker polls between protocol steps. Returns true only on the first call (the latch transition), letting callers post a one-shot "Abort key pressed" log line without spamming on key-mash.

Transfer.aborted

Read the abort flag without modifying it. true after a successful requestAbort().

Platform

OS identification. No further OS surface (no shell exec, no stdio, no process model) is exposed.

Method Description

Platform.name

OS identifier as a String. Returns "Windows" on Windows; the uname(2) sysname field on POSIX hosts (typically "FreeBSD", "Linux", "Darwin", etc.); "Unknown" on anything else. Win32 is checked before POSIX, so Cygwin / MSYS report the native OS.

Timer

One-shot fiber resumption after a delay. Registers a fiber to receive a TimerElapsed event after the requested number of milliseconds; the event lands on the result queue and the fiber is resumed by the standard drainer once doterm() reaches it.

For recurring fire-and-forget callbacks (no fiber resume), use Hook.every(ms, fn) instead.

Method Description

Timer.trigger(fiber, ms)

Schedule fiber to be resumed with a TimerElapsed after ms milliseconds. Multiple pending entries per fiber are fine — each fires independently. Returns null. Throws on a non-numeric or negative ms, or when more than 32 timers are pending across the whole VM.

Timer.now

Monotonic clock reading in seconds (a Num). The absolute value is arbitrary; only differences between two readings are meaningful. Useful for measuring elapsed time around an async op or computing throughput between snapshots.

Common idiom — fire and immediately await:

import "syncterm" for Timer

Timer.trigger(Fiber.current, 250)
Fiber.yield()
// 250ms have passed, with the doterm() loop running normally

Multi-fire / event-loop dispatch:

import "syncterm" for Timer, SFTP, SFTPStat, SFTPError

Timer.trigger(Fiber.current, 100)        // spinner tick
SFTP.stat(Fiber.current, "/foo")          // stat in flight
while (true) {
  var x = Fiber.yield()
  if (x is TimerElapsed) {
    spinner.update()
    Timer.trigger(Fiber.current, 100)    // re-arm
  }
  if (x is SFTPStat)  break
  if (x is SFTPError) break
}

TimerElapsed is a marker class with no fields — the caller already knows what it scheduled; just dispatch on type.

Format

Number-to-string helpers for human-readable display. Both methods auto-pick a unit based on the magnitude of the input and use one decimal of precision; negative inputs are clamped to zero.

Method Description

Format.bytes(n)

Format n bytes as a String with a K / M / G / T / P suffix (e.g. "1.4M", "100K", "42"). Uses 1024-byte units. Throws if n is not a Number.

Format.duration(seconds)

Format seconds as a String with a s / m / h / d / y suffix (e.g. "8.2s", "1.5m", "3.0h"). Throws if the argument is not a Number.

Combined idiom — display a transfer rate and ETA:

import "syncterm" for Format, Timer

var startedAt  = Timer.now
var startBytes = job.done
// ... later ...
var elapsed = Timer.now - startedAt
var moved   = job.done - startBytes
if (elapsed > 0) {
  System.print("%(Format.bytes(moved / elapsed))/s")
  var remain = job.total - job.done
  if (remain > 0) {
    System.print("ETA %(Format.duration(remain / (moved / elapsed)))")
  }
}

SFTP

SSH-channel side-band file transfer. The full surface follows the fiber-arg async pattern (see Async Ops below): the foreign primitive captures a fiber and queues work on the SSH session’s recv thread; the result lands on the framework’s queue and the standard drainer resumes the fiber.

SFTP.<op>(fiber, args…​) returns null when the request was queued (yield to receive the result), or an SFTPError directly on synchronous failure (session is gone, OOM at the foreign-method site).

Method Description

SFTP.available

true while the SSH connection has a usable SFTP subsystem.

SFTP.pubdir

The remote pubdir@syncterm.net path advertised by the server, or null if the extension wasn’t negotiated.

SFTP.lname

true when the session negotiated the lname@syncterm.net extension — the per-entry friendly long name (directories) / short description (files) that SFTPEntry.longname carries. UI code can use this to decide whether to reserve a row for a description bar that would otherwise always be blank.

SFTP.realpath(fiber, path)

Resolve path to an absolute server path. Resumes with String or SFTPError.

SFTP.stat(fiber, path)

Stat a remote path. Resumes with SFTPStat or SFTPError.

SFTP.opendir(fiber, path)

Open a directory for iteration. Resumes with SFTPHandle or SFTPError.

SFTP.readdir(fiber, handle)

Read the next batch of entries from an open directory handle. Resumes with List<SFTPEntry> or null (EOF) or SFTPError. Loop until null, then call SFTP.close.

SFTP.open(fiber, path, flags)

Open a file. flags is a bitmask from FileFlag (see below). Resumes with SFTPHandle or SFTPError.

SFTP.read(fiber, handle, count, offset)

Read up to count bytes from handle at offset. Resumes with the bytes as a String, null at EOF, or SFTPError. Argument order matches File.readBytes(count, offset).

SFTP.write(fiber, handle, offset, bytes)

Write bytes to handle at offset. All-or-nothing: resumes with null on success or SFTPError.

SFTP.close(fiber, handle)

Close a handle returned by open / opendir. Resumes with null or SFTPError. After close, the handle is dead — further reads / writes / closes on it abort the calling fiber.

SFTP.mkdir(fiber, path), SFTP.rmdir(fiber, path), SFTP.remove(fiber, path), SFTP.rename(fiber, oldpath, newpath)

Mutation ops. Each resumes with null on success or SFTPError.

SFTP.setMtime(fiber, path, t)

Set both atime and mtime of path to t (POSIX seconds). Used by the upload queue to stamp completed remotes with the local file’s mtime so a subsequent browse shows [==] without depending on hash extensions. Resumes with null on success or SFTPError (e.g. read-only mount).

SFTP.descs(fiber, path)

Fetch the server’s long description for path via the descs@syncterm.net extension. Resumes with the description String, null if there’s no description (or the extension wasn’t negotiated), or SFTPError. SFTPEntry.hasLongDesc is a per-entry hint — server-asserted presence — that lets browsers decide whether to mark a row as having a long description available without paying the round-trip cost on every selection.

FileFlag

OR-able bitmask constants for SFTP.open’s flags argument; each matches the corresponding `SSH_FXF_* wire constant.

Constant Wire value

FileFlag.read

0x01

FileFlag.write

0x02

FileFlag.append

0x04

FileFlag.creat

0x08

FileFlag.trunc

0x10

FileFlag.excl

0x20

SFTPEntry

One entry from SFTP.readdir.

Member Description

name

Filename relative to the parent directory.

longname

Server-formatted long name (when the lname@syncterm.net extension was negotiated; null otherwise). For directories this is the friendly long name (UI label); for files it’s the one-line short description (UI status bar).

size

File size in bytes.

mtime

Modification time, POSIX seconds.

isDir

true if the entry is a directory.

hasLongDesc

true if the server marked this entry as having a long description available via the descs@syncterm.net extension. Lets browsers show a "+" marker on rows with descriptions without paying the round-trip cost; fetch the actual text with SFTP.descs(fiber, path).

hash

SHA-1 / MD5 digest bytes from sha1s@syncterm.net / md5s@syncterm.net, or null if neither extension was negotiated. Compares directly against File.sha1 / File.md5.

SFTPStat

Result of SFTP.stat.

Member Description

size

File size in bytes.

mtime

Modification time, POSIX seconds.

atime

Access time, POSIX seconds.

mode

SFTP-wire permission bits (Unix-style on POSIX servers).

uid

Owner user ID.

gid

Owner group ID.

SFTPHandle

Opaque server file/dir handle. Only SFTP.open / SFTP.opendir produce one; only SFTP.read / SFTP.write / SFTP.readdir / SFTP.close consume one. GC’d handles fire SFTP.close fire-and-forget as a safety net, but scripts should close explicitly.

SFTPError

Returned in place of the typed result on any failure. Two error layers — distinguish via code:

Member Description

code

sftp_err_code_t enum value. Non-zero means a library / transport-level failure (OOM, ABORTED, SEND_FAILED, …); zero means the server returned a STATUS reply with an error code, in which case serverStatus carries it.

serverStatus

SSH_FX_* wire status (e.g. SSH_FX_NO_SUCH_FILE, SSH_FX_PERMISSION_DENIED). Meaningful only when code == 0.

message

Human-readable diagnostic text accumulated by the library (may be null).

isTransient

true for failures that may succeed on retry (transport drops, aborts, OOM).

The toString override produces "SFTPError: <name>: <message>" for System.print / interpolation; useful for log-line debug.

Async Ops

Every SFTP.<op>(fiber, …​) and Timer.trigger(fiber, ms) follow the same fiber-arg pattern. The first argument is the fiber that will be resumed with the result. The canonical idiom is the ||-yield pattern:

var r = SFTP.realpath(Fiber.current, ".") || Fiber.yield()
// r is String or SFTPError

The || short-circuits when the foreign returned a synchronous error (no session, OOM): r is the SFTPError immediately, no yield happens. Otherwise the foreign returned null, the yield fires, and the fiber resumes with the actual result (success or async error).

The single-source rule

This idiom is correct iff the calling fiber’s only resume sources are the async-op completions it’s making. If the fiber has other things that can wake it (Wake.post from the host, the App’s claim handler posting key/mouse/timer events to its run-fiber, etc.), the yield can return a value of the wrong type and the result-type check on the next line will see garbage.

In practice, every script context that calls SFTP.<op> is already single-source by construction. The three providers:

  • Hook handler fibers — the host invokes hooks (onConnect, onKey, etc.) in a fresh fiber; nothing else can wake it. auto/connected/sftp_pubkey.wren works this way.

  • SftpQueue worker fibers — the queue spawns one worker per direction; each is the long-running parker for its own ops. sftp_queue.wren works this way.

  • App.runChild children — an App handler runs in the App’s run-fiber, which IS multi-source. Wrap ||-yield work in App.runChild to get a single-source child fiber while the run-fiber keeps dispatching events:

    var r = app.runChild(Fn.new {
      return SFTP.realpath(Fiber.current, ".") || Fiber.yield()
    })

    sftp_app.wren works this way — every SFTP.<op> call inside SftpApp ultimately runs in a runChild fiber.

If you ever find yourself wanting to write SFTP.<op>(Fiber.current, …​) || Fiber.yield() directly inside a key/mouse handler or other App-handler context, that’s the multi-source-fiber footgun. Wrap it in runChild.

Callback shape (no yield)

For fire-and-forget — no need for an answer at all, or the result handler is naturally a closure — pass Fiber.new {|r| …​ } instead of Fiber.current. The calling fiber doesn’t yield:

SFTP.realpath(Fiber.new {|r|
  // r is String or SFTPError
}, ".")

The framework `.call`s the passed fiber with the result whenever the op completes. Common in hooks that don’t want to block on the result, and for fan-out where each result wants its own closure.

WON

The Wren Object Notation — round-trip serialisation between Wren values and a textual literal format. Output is a strict subset of valid Wren syntax (and therefore also pasteable into Wren source). Deserialisation runs a hardened C parser, not eval, so feeding untrusted text to WON.deserialize cannot execute code.

Supported types:

Type Notes

Null

null

Bool

true / false

Num

Num.toString form; NaN / Infinity have no Wren literal and are rejected

String

quoted with Wren-style escapes; % is always escaped to \% so output never starts an interpolation

List

[a, b, c]

Map

{key: value, …​} — keys must be hashable Wren types (Bool, Num, String, Range, null)

Range

coerced to List via .toList; serialisation is one-way (the deserialised value is a List)

Sequence

any other Sequence coerces to List via .toList

Values of any other type abort serialisation in strict mode and are silently omitted (or replaced with null at the top level) in lossy mode. Cyclic Lists / Maps abort in both modes.

Member Description

WON.serialize(value)

Strict compact form. [1,2,3] / {"a":1,"b":2}. Aborts the fiber on unsupported types, NaN/Infinity, or cycles.

WON.serialize(value, indent)

Strict pretty-printed form. indent is a String inserted once per nesting level (e.g. " " for two-space, "\t" for tab). Same error behaviour as the one-arg form.

WON.serializeLossy(value)

Compact form that silently omits unsupported items from Lists and Maps; a top-level unsupported value becomes "null". Cycles still abort.

WON.serializeLossy(value, indent)

Pretty-printed lossy form.

WON.deserialize(text)

Parse text and return the reconstructed value. On parse failure returns a WONError IN PLACE of the parsed value (the fiber is NOT aborted — WON.deserialize is the prototypical consumer of untrusted input, and a corrupt config file shouldn’t kill the script). Tolerates arbitrary whitespace and trailing commas inside […​] / {…​}. Accepts exactly the string-escape set the Wren compiler itself recognises. Programmer errors (passing a non-String) DO still abort.

WONError

Returned IN PLACE of the parsed value when WON.deserialize fails. The script demuxes via is WONError. Mirrors the SFTPError / FileError / ConnError shape.

Member Description

code

WONErr.* enum value.

offset

Byte offset where the error was detected. Useful for flagging a position in the input.

message

Human-readable diagnostic, may be null.

toString

"WONError: <CODE-NAME>: <message> at offset <n>".

WONErr constants:

Constant Code When emitted

WONErr.ok

0

Sentinel; never appears on a returned WONError.

WONErr.syntax

1

Expected token / unexpected character / malformed number.

WONErr.truncated

2

Unterminated string, premature end of input.

WONErr.invalidEscape

3

Malformed \x / \u / \U escape, or unknown \x letter.

WONErr.invalidKey

4

A List or Map was used as a Map key.

WONErr.tooDeep

5

Nesting exceeded the parser’s recursion budget (256 levels).

WONErr.trailingData

6

A complete value was followed by more (non-whitespace) data.

OOM during parsing aborts the fiber (no recoverable path — the parser can’t allocate a WONError to return either).

import "syncterm" for WON

var data = {
  "name":  "syncterm",
  "items": [1, 2, [3, 4]],
  "flags": {"verbose": true, "depth": 7},
}

System.print(WON.serialize(data))
// {"name":"syncterm","items":[1,2,[3,4]],"flags":{"verbose":true,"depth":7}}

System.print(WON.serialize(data, "  "))
// {
//   "name": "syncterm",
//   "items": [
//     1,
//     2,
//     [
//       3,
//       4
//     ]
//   ],
//   "flags": {
//     "verbose": true,
//     "depth": 7
//   }
// }

var roundTripped = WON.deserialize(WON.serialize(data))
System.print(roundTripped["flags"]["depth"])     // 7

// Lossy mode skips unsupported entries instead of aborting.
var mixed = [1, Fiber.new {}, 2, Fiber.new {}, 3]
System.print(WON.serializeLossy(mixed))          // [1,2,3]

Built-in UI Library

SyncTERM ships a pure-Wren widget library on top of the Modal Input primitive. It draws into Surfaces with no foreign calls per cell, composites them through a screen-sized backbuffer, and applies a final Screen.putRect per frame, so the whole thing runs without flicker even on slow remote BBSes. The visual style follows SyncTERM’s classic conventions: cyan-on-blue dialog frames, yellow titles, lightbar selection, drop shadows on modals, and a distinct cyan inactive palette for any pane that’s currently behind a modal.

A minimal session looks like:

import "ui" for App, Pane, ListView, Alert, Rect
import "syncterm" for Screen

var snap = Screen.save()

var app  = App.new()
var size = Screen.size
var pane = Pane.new()
pane.bounds  = Rect.new(2, 2, size[0] - 2, size[1] - 2)
pane.title   = "Pick one"
pane.focused = true
pane.onClose = Fn.new { app.quit() }
app.root.add(pane)

var list = ListView.new()
var ib   = pane.innerBounds
list.bounds = Rect.new(ib.x, ib.y, ib.w, ib.h)
list.items  = ["alpha", "beta", "gamma"]
list.onSelect = Fn.new {|i, item| Alert.show(app, "Picked: %(item)") }
pane.add(list)

app.runSync()
Screen.restore(snap)

App.run() and App.runSync() differ in how they pump events. run() parks a fiber on Fiber.yield() between events — claim handler posts each accepted event via Wake.post, the result queue resumes the fiber. Inbound bytes flow normally, Hook.every and Timer.trigger keep firing. runSync() blocks the VM on Input.next() instead; use it from contexts where doterm can’t dispatch back into the App, e.g. the Wren console (whose REPL itself runs inside a Hook.onKey).

runSync() dispatches each key or mouse event in a child fiber. A runtime abort from a synchronous handler therefore does not unwind the App’s event loop. By default the App writes the error and trace to the current VM’s console log; set app.onError = Fn.new {|failedFiber| …​ } to replace that reporting. Layout and painting remain outside this boundary so a deterministic redraw failure is not retried forever.

Key.quit is reserved for the host’s sticky process-close request. App claims it before ordinary widget or global-keymap dispatch. With a modal open, the App sends that modal an Escape for normal cleanup and then removes it if it did not close itself; subsequent pump iterations unwind the remaining Wren call stack. With no modal, the App stops. A widget whose atExit property is true is the explicit exception: it remains interactive while the quit latch is set, and modals it opens inherit atExit. Use this only for work that must finish or be decided during shutdown, such as a save confirmation. The connected-session host handles a real process-close request before dispatching to the connected VM, so remotely supplied scripts cannot use atExit to delay termination.

Importing

Every public class is exported from individual ui_*.wren modules and re-exported from the convenience aggregator ui:

import "ui" for App, Pane, ListView, TextInput, SelectOnFocusInput, Button,
               Checkbox, RadioGroup, SpinBox, MenuBar, StatusBar,
               Form, Alert, Confirm, Prompt, Find, Help, PopStatus,
               Painter, Style, Theme, Glyphs,
               Widget, Container, Rect

If you only need a couple of classes, the per-topic modules cut load cost: import "ui_pane" for Pane, import "ui_popup" for Confirm, etc. They’re listed in UI Modules.

App

App is the root of a UI session. It owns:

  • root — a Container that hosts the foreground widget tree.

  • a modal stack — synchronous dialogs push themselves onto it via app.modal(widget), which blocks until the widget pops itself (typically from its own dismissWith_(value) helper).

  • a global keymap — app.bind(keyCode, fn) registers a hotkey that fires when no widget consumes the key. F1 is bound by default to showHelp.

  • a Theme initialized from Theme.current, which decodes the C-owned active theme and refreshes its per-VM cache when Host.themeGeneration changes. The menu, picker, and connected VMs therefore use the same selected theme. Assign app.theme only when the App intentionally needs a different per-App theme. Assignment invalidates the complete root, transient-status, and modal widget trees so every mounted widget repaints on the next draw.

  • a single run-fiber that pumps the event loop. run() pushes an input claim (see Modal Input) whose handler decides synchronously, from App state alone, whether each event is "for the App" — modal up consumes everything; with a focused widget, keys are claimed and mouse events are claimed when they hit the visible widget tree (or start a drag, which the App owns for rectangular select); otherwise the event falls through to lower claims and then to registered Hook.onKey / Hook.onMouse. Claimed events are delivered to the run-fiber via Wake.post; the next drainOnce_ iteration picks them up and dispatches into the widget tree.

  • a backbuffer Surface sized to the term area (Screen.size minus one row when CTerm.statusDisplay > 0, so the App’s blit doesn’t overwrite the SyncTERM status bar) and a one-shot screen capture (Screen.readRect) used as the backdrop behind everything. Areas not covered by widgets show that capture rather than a styled fill.

Methods of interest:

  • run() / runSync() — enter the event loop. Save/restore mouse events and CustomCursor automatically. tickMs= schedules a periodic onTick_ callback (overridable; no-op by default). Only one timer is queued at a time per App: the _tickPending guard means non-tick events (keys, mouse, external posts) don’t accumulate timers behind the pending one. runSync() contains runtime errors raised by key and mouse handlers as described above.

  • onError=(fn) — replace runSync()’s default reporting for a failed event handler. The callback receives the failed `Fiber, whose error and stackTrace describe the abort.

  • quit() — break out of the loop. Widgets and global handlers call this from their key/mouse handlers.

  • modal(widget) — push a modal, drain events until it pops, return the widget so the caller can read its result. A modal whose closesOnOutsideClick(event) method accepts a mouse event receives an Escape when that event occurs outside its bounds. List-style popups accept button 1 and right-click; Help accepts any ordinary mouse-button click. Text prompts and specialized editors reject outside clicks.

  • popStatus(message) — show or clear a transient centered overlay that does not intercept input. Useful for "Working…​" status while blocking on Input.next or a long Wren computation. The overlay sits above the foreground widget tree but below the modal stack, so a dialog the user is actively working with isn’t obscured by an indicator behind it.

  • releaseFocus() / restoreFocus() — wrap a picker call, where another VM temporarily owns the screen, so the App’s foreground tree doesn’t also draw as focused. Without this, the picker host’s pre-call screen save captures the App’s panes in their focused colour scheme, and the post-call restore re-paints them that way until the next App-driven repaint — visually, two widgets are focused at once. Operates on modalTop (so a release while a modal is up defocuses the modal’s leaf, not the root’s) and forces an immediate paint so the unfocused state lands on screen before the host UI saves it. Both are idempotent: a second releaseFocus before restoreFocus is a no-op, and restoreFocus without a prior release is a no-op.

    releaseFocus()
    var picked = Host.pickFiles(Host.uploadPath, "*", 0)
    restoreFocus()
  • showHelp() — walks from the focused leaf up the parent chain to the first widget with helpText set, then opens a Help dialog with that text. No-op if nothing in the chain has help.

  • post() / post(value) — wake the App’s run-fiber from outside the input / timer paths. See "Externally-driven repaint" below.

  • onPost=(fn) — handler for posted values that aren’t the no-payload sentinel. See below.

  • runChild(fn) — spawn fn in a fresh fiber, pump the App’s event loop from the run-fiber until the child completes, return whatever fn returned. See "Async work in handlers" below.

  • onLayout=(fn) — register a callback receiving (width, height) on the first paint and whenever the effective App area changes size. The root Container is resized before the callback runs, allowing the callback to assign stable responsive bounds to its children.

  • onUnhandledMouse=(fn) — register a callback receiving (event, hit) after a non-modal mouse event is declined by the hit widget. hit is the declined Widget, or null when the event is outside the root. Returning true consumes the event; returning false leaves normal App fallback handling, including right-click Escape and drag selection, in place. The callback is not invoked while a modal owns input.

  • theme= — install a custom Theme (see Theme).

Async work in handlers

runChild is the App-context provider for the single-source fiber that the ||-yield SFTP idiom (see Async Ops) requires. A keymap or widget handler that needs to call SFTP.<op> can’t do it directly: the App’s run-fiber has multiple resume sources (the claim handler’s Wake.post for keys/mouse, the tickMs timer, external app.post(value) calls), and any of them might wake the fiber before the SFTP result arrives. The yield then returns the wrong type and the calling code sees garbage.

runChild(fn) spawns a child fiber whose only resume sources are its own SFTP / Wake.post completions:

fetchDescAndShow_(path) {
  popStatus("Fetching ...")
  var d = runChild(Fn.new {
    return SFTP.descs(Fiber.current, path) || Fiber.yield()
  })
  popStatus(null)
  if (d is SFTPError) Alert.show(this, "Failed: " + d.toString)
  else                Alert.showPreformatted(this, d)
}

The child fiber yields on SFTP.descs; the run-fiber drops into its own drainOnce_ loop, dispatching keys, mouse, ticks, and repaints normally while the child is parked. When the SFTP result wakes the child, its body completes and runChild returns the value. Modal calls (Alert, Confirm) belong on the run-fiber side after runChild returns — modals pump drainOnce_ from whichever fiber called them, and only the run-fiber receives wakes from the App’s claim handler.

Externally-driven repaint

The async run() parks the run-fiber on Fiber.yield() between events. A network-driven app (chat client, ticker, log viewer fed by remote bytes) needs a way for Hook.onInput to nudge the UI without faking a key press. That’s what post is for:

import "syncterm" for Hook
import "ui_app"   for App

var app   = App.new()
// ... build widget tree ...

Hook.onInput { |b|
  buffer.add(b)              // mutate state
  someWidget.markDirty()      // mark UI as needing repaint
  app.post()                  // wake the App so drainOnce_ runs again
  return false
}

app.run()

app.post() calls Wake.post(runFiber, WakeOnly.instance) — a no-payload sentinel. The next main-loop drain delivers it, drawAll runs at the top of drainOnce_, and the dirty widget repaints. The hook returns synchronously — post only enqueues, satisfying the hooks-must-run-synchronously rule.

app.post(value) carries an arbitrary Wren value. When a non- sentinel value arrives, onPost (if set) is called with it after the redraw. Pass anything you can recognise:

app.onPost = Fn.new {|v|
  if (v is String && v == "irc.PRIVMSG") chatPane.scrollToBottom()
}

Hook.onInput { |b|
  // ...parse, accumulate...
  if (gotPrivmsg) app.post("irc.PRIVMSG")
  return false
}

post is a no-op when the App isn’t running (no run-fiber). It’s not supported under runSync — the synchronous variant blocks on Input.next() at the C level, not on Fiber.yield, so a wake through the result queue won’t reach it. Use run() for any app that needs external wake-ups.

App is not itself a Widget but exposes effectiveTheme and markDirty() so widget tree-walks can terminate at it. Setting a widget’s parent to an App is supported and is what pushModal does internally.

Widget

Base class for everything paintable. Holds:

  • bounds — a Rect in 1-based screen coords.

  • parent — pointer to the containing Container or App.

  • theme= — a per-widget Theme override; otherwise inherited via effectiveTheme walking the parent chain.

  • focused, visible, focusable, activitySensitive, dirty — boolean state flags. Setting activitySensitive to false makes ambient screen chrome use its base theme roles regardless of modal activity; it does not affect focus or input dispatch.

  • atExitfalse by default. When true, this widget remains interactive after a sticky process-close request. Modals pushed while it is active inherit the property. Ordinary widgets are unwound through Escape and cannot consume Key.quit themselves.

  • helpText= — string surfaced by App.showHelp (F1).

  • closesOnOutsideClick(event) — returns whether the App should send Escape to this widget when event occurs outside its modal bounds. The base implementation returns false.

  • shadow= — when true, the parent paints a drop shadow on the cells immediately right and below the widget’s bounds.

  • surface — the widget’s private Surface backbuffer, allocated lazily and resized when bounds changes.

  • preferredWidth, preferredHeight — auto-layout sizing hints. Default null (no preference, fill whatever bounds the parent gives). Widgets with a natural minimum size (ListView with N items, a Form with measured rows, …) override these so containers that know how to lay out children (Pane.fitContent) can size themselves around the content.

Layer-aware painting: every widget tracks the App’s active layer state (whether it or an ancestor is the modal top of stack) and repaints itself when the cached state no longer matches. No tree-wide dirty pass is needed when modals push or pop — each widget notices on its next draw() and repaints with the inactive theme variant. Subclasses override onPaint_() to draw into surface; the base Widget.draw() calls onPaint_(), clears the dirty flag, and returns the surface.

Hardware cursor: cursorPos returns [x, y] in 1-based screen coords (or null); cursorVisible returns whether the cursor should be shown while focused; cursorShape optionally returns "normal" or "solid"; and cursorAttr optionally returns the legacy text attribute whose foreground colors the cursor. App reads all four off the focused leaf each frame and applies them. A visible null shape defaults to "normal"; a null attribute leaves the current conio cursor color unchanged. Containers forward the focused child’s values.

tryHotkey(ev) is a parent-driven hotkey hook. Container.handle scans its children with this when a printable key fell through the focused child, so a typed letter can activate a button advertising that letter even when focus is elsewhere.

Container

Widget subclass that manages a child list, a focused-child index, and event dispatch. add(child) / remove(child) mutate the tree; the first focusable child added becomes focused automatically.

Focus traversal:

  • Tab / RightfocusNext(), ordered ring with wrap.

  • BackTab / LeftfocusPrev().

  • Up / Downspatial nearest-focusable scan. Picks the focusable child whose centre is above / below the current focus’s centre and minimises Manhattan distance. Ties (children on the same row) go to the lower-indexed sibling — i.e. tab order.

Spatial Up/Down does not wrap; it falls back to a hotkey scan if nothing matches. Widgets that need arrows for their own semantics (TextInput consumes Left/Right, ListView consumes Up/Down) catch the keys before they bubble.

Mouse: Container.hitTest(px, py) walks children top-to-bottom (last-added wins) and returns the deepest visible widget covering the point, or the Container itself when nothing under it does.

focusedIndex is readable as a property and writable as a setter for explicit focus management — e.g. the App-level releaseFocus() / restoreFocus() pair clears and restores it around blocking host UIs. The getter returns null when no child is focused, otherwise an integer in 0..count - 1. The setter toggles .focused on the previously- and newly-focused child; pass null to clear focus. Direct day-to-day focus changes still go through focusNext / focusPrev etc., not this setter.

Pane

Container with a frame, optional title, [?] and [X] corner buttons, and an inset interior. The default is a control-bearing pane: the stronger double-line frame, title in its own bar row with a horizontal separator underneath, and both corner buttons enabled.

Configuration knobs:

  • frameKind="control" (the default) or "display". A control frame marks a pane that contains choices, buttons, or editable input and uses the stronger double-line family. A display frame marks informational content and uses the single-line family. Assigning any other value aborts the calling fiber.

  • titleAsBar= — when true, the title sits inside the frame in its own row with a horizontal separator underneath. When false, the title is embedded in the top border (-| Title |-). Classic list views use the bar form; Popup subclasses use the embedded form.

  • helpable= / closeable= — show or hide the [?] and [X] corner buttons. Help button calls onHelp if set, else App.showHelp(). Close button calls onClose if set.

  • onHelp=, onClose= — function callbacks for the corner buttons.

pane.innerBounds returns the drawable interior Rect after the frame, title row, and separator are accounted for — children should size themselves against this.

pane.contentBounds is innerBounds inset by one cell on every side so text never touches the frame. Popup-style widgets (Alert / Confirm / Prompt / PopStatus / Help) lay themselves out inside contentBounds; bare Pane use sites that want the full frame interior (e.g. a ListView filling its parent pane) keep using innerBounds.

Pane.focused is a visual flag — set it from the App or a parent container when the pane is the foreground. It does NOT redirect keyboard focus; that still flows through `Container’s normal focused-child routing to a leaf widget inside.

Auto-layout

Pane.fitContent() sizes the pane around its single child’s preferredWidth / preferredHeight, plus whatever the title bar and corner buttons require. It repeats measurement after assigning the child’s bounds, so preferences that depend on the resulting viewport, such as a ListView scrollbar, settle in one call. The width is the largest of:

  • the child’s preferredWidth + 2 (left + right border),

  • the title’s outer-width demand (see below), and

  • the corner buttons' outer-width demand (each visible button contributes 3 cells; total + 2 corners).

The two title modes have different geometry:

  • titleAsBar — title is centered on its own row inside the frame; outer width = title + 4 (frame(2) + 1-cell padding on each side so the title never butts up against the side frame).

  • frameTitle (titleAsBar = false) — title sits in the top frame border between ─ ` and ` ─ brackets; outer width = title + 6 (corners(2) + brackets(2) + spaces(2)).

Multi-child panes are not auto-sized — fitContent only inspects the first child. Lay them out manually via bounds / add.

Pane.fitContent(maximumWidth, maximumHeight) performs the same layout while constraining the complete pane to the supplied maximum dimensions. Pane.fitContentToScreen() applies the standard modal limits, leaving room for the two-column right shadow and one-row bottom shadow, and centers the result. For a pane with a requested fixed size rather than a child-defined size, Pane.fitToScreen(width, height) applies those same limits and centering.

Pane.centerOnScreen() repositions the pane so its bounds are centered on the current Screen.size, then re-anchors its single child into the new innerBounds. Use fitContentToScreen() for the usual constrained and centered modal layout:

var pane = Pane.new()
pane.title = "ANSI Music Setup"
pane.add(list)
pane.fitContentToScreen()

ListView

Scrollable list of items with a selection cursor and an optional scrollbar. Items can be anything; rendering goes through formatItem(item, width) which subclasses can override for rich per-row formatting. Default is item.toString.

Setup:

var list = ListView.new()
list.bounds   = Rect.new(x, y, w, h)
list.items    = ["alpha", "beta", "gamma"]
list.onChange = Fn.new {|i, item| /* selection moved */ }
list.onSelect = Fn.new {|i, item| /* ... */ }

Navigation: Up, Down, PageUp, PageDown, Home, End move the selection. Enter fires onSelect with (index, item). Mouse: a single click on a row both selects it and fires onSelect; each wheel notch over the list content moves the selection one row using the same wrapping rules as Up and Down. Over the scrollbar, a wheel notch moves one page. The scrollbar’s top and bottom arrow cells invoke PageUp and PageDown; track clicks jump proportionally while keeping the lightbar on the same screen row. The separator column between content and scrollbar is inert. Button-1 drags fall through to screen selection.

wrap= controls single-row Up/Down movement and defaults to true: Up at the first row wraps to the last, and Down at the last wraps to the first. Set it to false to clamp at the ends. PageUp/PageDown, Home/End, and direct selected= assignments do not wrap.

onChange is independent of activation. It fires with (index, item) whenever navigation, assignment, search, or a row click changes the current row. Replacing items also fires it so a detail pane can refresh even when the selected numeric index remains zero. An empty list reports (null, null).

items= preserves the selected index and scrollTop when replacing a populated list, clamping them only when the replacement is shorter. This allows a menu to rebuild changed row labels without moving its viewport. resetItems(list) replaces the collection and explicitly selects its first row at scrollTop=0; use it when navigating to a different collection rather than redrawing the current one.

selected is null when no row is selected (empty list, or explicitly cleared with list.selected = null); otherwise an integer in 0..count - 1. selectedItem mirrors the same contract — null when nothing is selected, the item otherwise. When a selection is assigned before the list has bounds, first layout centres an offscreen selected row where possible. A selection already in the initial viewport leaves that viewport at the top. Normal navigation scrolls only far enough to keep the moving selection visible.

The selected row is highlighted even when the list does not have focus. Set highlightWhenUnfocused=false in a multi-list control when only the list receiving keyboard input should display its selection lightbar. This does not apply the inactive palette to the other list.

Two complementary search modes, both case-insensitive (ASCII fold) and wrapping at the end of the list:

  • Type-to-search — any printable character grows a rolling buffer and jumps to the first item whose search text starts with it. If the new buffer doesn’t match anywhere, falls back to just the new character (fresh prefix from current+1). Buffer resets on any navigation or activation key, so a paused-then-resumed search starts clean.

  • Ctrl-F / Ctrl-G — Ctrl-F opens an inline Find dialog (see the Popup family) for a substring query, jumps to the first match. Ctrl-G repeats the last query.

The hook searchTextFor_(item) returns the string to match against; defaults to formatItem(item, 1024) (the normal display string). Override in subclasses when the displayed row decorates the underlying text — the SFTP browser’s BrowserListView, for example, returns item.name so users type bare filenames rather than the chip-prefixed [==] foo.dat line.

Tag mode

selectionMode= accepts "single" (default) or "tag". In tag mode each row tracks an independent tag flag, toggled by Space on the current row; tagged returns the indices that are flagged on. A 1-cell marker column appears at the leftmost content position using the theme’s tag.on / tag.off glyphs (» / blank by default; > / blank in ASCII-only mode). Switching modes resets the flags; so does setting items=.

Scrollbar layout knobs:

  • scrollbarSide="right" (default) or "left".

  • scrollbarSeparator= — when true, paints a | divider between the scrollbar column and the content area.

  • showScroll= — set to false to disable the scrollbar entirely.

The scrollbar is only painted when items.count > bounds.h.

ListView always reserves a 1-cell padding between the frame and the items on the side that doesn’t have a scrollbar (both sides when no scrollbar is shown at all), so item text never butts up against the frame the parent Pane draws. innerWidth is bounds.w - leftInset - rightInset accordingly.

Auto-layout: preferredWidth returns the longest item’s display length plus the active insets (left + right padding, or scrollbar + padding when overflow is expected); preferredHeight returns items.count, clamped to at least one row. The parent layout owns screen and container constraints. Combined with Pane.fitContentToScreen, a list-in-pane modal sizes itself to its contents:

import "ui_pane" for Pane
import "ui_list" for ListView

var list = ListView.new()
list.items = Host.musicNames

var pane = Pane.new()
pane.title = "ANSI Music Setup"
pane.add(list)
pane.fitContentToScreen() // sized to list, constrained, and centered

TextInput

Single-line text edit field. Stores the value as a list of codepoint strings so the cursor indexes by codepoint and emoji / multibyte characters take one cell per codepoint.

var t = TextInput.new()
t.bounds   = Rect.new(x, y, w, 1)
t.value    = "hello"
t.maxLen   = 64                  // optional cap
t.mask     = "*"                 // optional display-only mask
t.onSubmit = Fn.new {|s| /* Enter */ }
t.onChange = Fn.new {|s| /* per-keystroke */ }
t.selectAll()                     // optional replace-all entry state

Keys use SyncTERM’s standard line-editor behavior. Left, Right, Home, End, Backspace, and Delete / DelChar move and delete; Ctrl-B and Ctrl-E alias Home and End, and Ctrl-Y truncates at the cursor. Insert toggles insert/overwrite mode. Ctrl-C / Ctrl-Insert copy the whole field, Ctrl-X / Shift-Delete cut it, and Ctrl-V / Shift-Insert paste at the cursor. Ctrl-Z opens the focused field’s contextual help when one is available. Enter fires onSubmit; printable codepoints edit at the cursor. Tab, BackTab, Up, and Down are NOT consumed so containers can use them for focus traversal. A left click positions the cursor; middle- and right-click paste there. An unconsumed right-click elsewhere is Escape. Drag events fall through to the App’s screen-selection handler.

selectAll() puts a non-empty value into a transient whole-field selection state, reported by the read-only allSelected getter. The next printable, keyboard paste, Backspace, Delete, or Ctrl-Y operation replaces the complete value. Cursor movement and mouse positioning preserve the value and dismiss the selection before ordinary editing continues; other non-editing key events also dismiss it without changing the value. Assigning value clears the state, so callers opt into it explicitly after assignment when needed.

SelectOnFocusInput is the existing-value editing variant used by SyncTERM prompts: prompts and inline editors. It calls selectAll() whenever it gains focus, clears the selection on focus loss, highlights only the selected text, and uses the surrounding default style after cursor movement begins ordinary editing. Use TextInput when focus should leave an assigned value ready for appending; use SelectOnFocusInput when typing should replace that value.

insertMode reports whether printable input inserts (true, the initial mode) or overwrites existing characters (false). The mode is shared by all TextInput instances, so toggling it in one field applies to the next field. cursorShape is "normal" in insert mode and "solid" in overwrite mode, matching SyncTERM’s insert/overwrite cursor convention. cursorAttr supplies the focused input style’s legacy attribute so bitmap backends render the cursor in the field foreground instead of an unrelated global color. Pasted control characters are discarded, matching typed-input filtering, and maxLen applies to pasted text.

mask= accepts null or a non-empty String and uses its first codepoint when painting every stored character. It does not change value, cursor movement, length, or callback arguments, making it suitable for password and system-password fields.

Assigning or resizing bounds recalculates the horizontal viewport. An initial value wider than the field displays its end followed by a cell for the insertion cursor, regardless of whether value or bounds was assigned first. scrollOff reports the first displayed codepoint index.

cursorVisible returns true and cursorPos reports the cell that corresponds to the current insertion point in screen coords — backends that show the hardware cursor track the input as the user types.

Button

Single-row "[ label ]" widget. Activates on Enter, Space, or mouse click within bounds. onPress= sets the activation callback. intrinsicWidth returns label.count + 4 for sizing parents.

var b = Button.new("OK")
b.bounds  = Rect.new(x, y, b.intrinsicWidth, 1)
b.onPress = Fn.new { app.popModal() }

hotkeyIdx= selects which label letter to highlight (default 0, the first letter). tryHotkey matches that letter case- insensitively against typed input, so a button labeled "Yes" with hotkeyIdx = 0 activates on Y from anywhere in the same container — even when focus is on a sibling.

Checkbox

Single-row "[X] Label" toggle. value is a Bool, label is a String. Space or Enter flips the value when focused; mouse click anywhere on the widget toggles too.

var c = Checkbox.new("Show shadows")
c.bounds   = Rect.new(x, y, c.intrinsicWidth, 1)
c.value    = true
c.onChange = Fn.new {|v| /* ... */ }

onChange fires only when the value actually changes — assigning the current value is a no-op. Theme roles: checkbox, checkbox.focused. Glyphs reused: check.on ( U+221A), check.off (space).

RadioGroup

Multi-row mutually-exclusive selector. Owns its own item list and selected index — the whole group is a single focusable widget.

var g = RadioGroup.new()
g.bounds   = Rect.new(x, y, w, h)
g.items    = ["None", "SSL", "SSH"]
g.selected = 0
g.onChange = Fn.new {|i, item| /* ... */ }

Two pointers: cursor (visually highlighted row) and selected (the committed value). Up/Down move the cursor; Home / End jump to ends; Space or Enter commits the cursor as the selection. Mouse click selects directly. onChange fires only when the selection actually changes. Both pointers are null when the group is empty (or explicitly cleared by assigning null); otherwise integers in 0..count - 1. The cursor highlight only appears while the widget itself has focus — leaving the group shows just the filled glyph on the selected row, so a multi-field Form doesn’t confuse the user with a stale lightbar.

wrap= controls the edge behavior of Up/Down. Default true: Up at the top wraps to the bottom and vice versa; every press is consumed. When false, Up at the top and Down at the bottom return false instead, letting the parent Container move focus to the previous / next sibling. Form.addField flips this to false on RadioGroup children so vertical traversal escapes the group at its edges.

Theme roles: radio.item, radio.item.focused. Glyphs reused: radio.on ( U+2022), radio.off ( U+25CB).

SpinBox

Single-row numeric input with up/down step buttons. Layout: [ 42 ▲▼].

var s = SpinBox.new()
s.bounds   = Rect.new(x, y, w, 1)
s.min      = 0
s.max      = 100
s.step     = 1
s.value    = 50
s.onChange = Fn.new {|v| /* ... */ }

Keys: Up / + adds step; Down / - subtracts; PageUp / PageDown move ten steps; Home / End jump to min / max. Mouse wheel scrolls one step; clicks on the up/down arrow cells step ±1. value= clamps to [min, max]. Theme roles: spinbox, spinbox.focused. Glyphs reused: scrollbar.up, scrollbar.down.

MenuBar

Horizontal strip of activatable items, typically at the top of the screen. Each item is a [label, Fn] pair; activation calls the Fn. No nested submenus in v1 — pop a Popup from the callback if you want one.

var bar = MenuBar.new()
bar.bounds = Rect.new(1, 1, sz[0], 1)
bar.items  = [
  ["File", Fn.new { Alert.show(app, "...") }],
  ["Edit", Fn.new { /* ... */ }],
  ["Help", Fn.new { app.showHelp() }],
]

Keys: Left / Right move focus between items with wrap; Home / End jump to ends; Enter / Space activate the focused item; a typed letter (case-insensitive ASCII) matched against item labels' first characters activates that item. focusedItem is null when the bar is empty (or explicitly cleared); otherwise an integer in 0..count - 1. tryHotkey(ev) is exposed on the widget so a Container parent can route a letter pressed elsewhere into the bar’s matcher. Mouse click activates the clicked item; clicks on inter-item gaps are dropped. Theme roles: menubar, menubar.item, menubar.item.focused.

StatusBar

Single-row strip used at the bottom (or top) of the screen for status text and key hints. Not focusable.

var s = StatusBar.new()
s.bounds = Rect.new(1, sz[1], sz[0], 1)
s.text   = "F1 Help  Esc Quit"

// or — multi-segment:
s.segments = [
  ["F1 Help",   "left"],
  ["Connected", "center"],
  ["09:42",     "right"],
]

text= sets a single left-aligned string. segments= takes a list of [text, align] pairs where align is "left", "center", or "right". Left segments stack from the left edge with a 2-cell gap; right segments flush to the right edge inward; one center segment is centred in the remaining space. Setting either form overrides the other. Theme role: statusbar (black on cyan).

Form

Container subclass that lays out (label, widget) pairs in a vertical stack with right-aligned labels in a column on the left. Optional OK / Cancel buttons appear on the row below the last field when onSubmit / onCancel are wired.

var f = Form.new()
f.bounds = pane.innerBounds

f.addField("Name:", nameInput)
f.addField("Port:", portSpin)
f.addFieldH("Encryption:", encRadio, 3)    // 3 rows tall
f.addField("",  autoCheckbox)              // no label

f.onSubmit = Fn.new { /* read field values */ }
f.onCancel = Fn.new { app.quit() }
pane.add(f)
  • addField(label, widget) — queues a one-row field.

  • addFieldH(label, widget, h) — queues a multi-row field (e.g. RadioGroup).

  • clearFields() — drops every queued field and removes the widgets as children.

  • rowGap= — blank rows between fields. Default 0.

  • rowH= — default per-field height. Default 1.

The label column width auto-sizes to the longest label; widgets start one column past the longest label plus a 2-cell gap. Tab / BackTab traversal hits widgets in declaration order, then OK, then Cancel. Esc fires onCancel if wired.

onSubmit= / onCancel= are idempotent setters — assigning the same callback twice doesn’t double-add the OK / Cancel button, and assigning null removes the button.

Popup family

Modal dialogs built on Pane (control frame, embedded title, drop shadow) and pushed onto the App’s modal stack. Each show is synchronous: the App’s modal() pumps drain until the popup pops itself, then control returns with the result. Help is the informational exception and selects the display frame; transient PopStatus uses the display frame as well.

import "ui" for Alert, Confirm, Prompt, LinePrompt

Alert.show(app, "Disk full")
Alert.show(app, "Warning", "Disk full")
Alert.show(app, "Warning", "Disk full", "# Disk Full\n\nFree space first.")

if (Confirm.show(app, "Delete file?", "# Delete\n\nThis cannot be undone.")) {
  // ...
}

var name = Prompt.show(app, "Your name?", "anonymous")
if (name != null) {
  // user submitted
}

var path = LinePrompt.show(app, "Path", "/tmp")

Alert — single OK button. Enter, Space, O, Esc, or a mouse click on OK dismisses it; unrelated keys do not. show returns null.

Confirm — Yes / No buttons. Y / N letter shortcuts dismiss directly; Enter activates the focused button; Tab cycles focus. show returns true on Yes, false on No or Esc.

The four-argument titled Alert.show(app, title, message, helpText) and three-argument Confirm.show(app, message, helpText) forms attach Markdown context help to the popup. F1 and Ctrl-Z display that text while the popup owns focus.

PromptSelectOnFocusInput plus OK / Cancel buttons. A non-empty initial value starts selected: typing, keyboard paste, Backspace, or Delete replaces it, while cursor movement or mouse positioning preserves it and begins ordinary editing. Enter in the input or on OK submits the value; Esc or Cancel returns null. The read-only input getter exposes that input so trusted UI code can set maxLen or a display-only mask before presenting the popup. sizeForInput(inputWidth) and sizeForInput(inputWidth, minimumWidth) size the field for the requested capacity and cap the complete popup to the standard screen margins. The MenuUi.prompt wrappers use the field’s maxLen for this width. Short fields retain the normal minimum while URI, path, password, and other long fields use the available screen width.

LinePrompt — compact UIFC-style single-row entry. Its label appears to the left of the input on the only interior row; there is no OK / Cancel button row. Enter submits and Esc returns null. The read-only input getter and sizeForInput methods have the same purpose as Prompt’s. A distinct optional title may be placed in the top frame, but menu prompts omit it when it would merely repeat the field label. MenuUi.prompt uses this form for ordinary menu editing and application filename entry; host prompts retain Prompt because their explanatory message is separate from the dialog title. LinePrompt.runStandalone(app, label, initial) is the standalone equivalent.

Find — compact single-row variant of Prompt. Title sits in the top frame border, the input fills the entire innerBounds row (no padding, no buttons, no [X]). Total height is 3 cells. Its previous query starts selected under the same replacement rules as Prompt. Used by `ListView’s Ctrl-F.

import "ui" for Find

var q = Find.show(app, "Find", lastQuery)
if (q != null && q.count > 0) {
  // ...
}

Layout

All popups use the standard frame interior padding via Pane.contentBounds — a 1-cell pad on every side so text never touches the frame. Buttons (Alert OK, Confirm Yes/No, Prompt OK/Cancel) anchor hard against the bottom frame; the empty row between the message and the button row falls out of the height math as a natural visual separator.

Popup.centeredBounds_(message, extraRows, minW) sizes the dialog around the wrapped message: width is longest-line + 4 (frame
padding), height is 4 + msgRows + extraRows (2 frame + 2 padding
content). Subclasses pass extraRows for whatever they paint below the message — 1 for Alert/Confirm (button row), 2 for Prompt (input + button rows).

PopStatus is the non-modal companion: a centered frame with a message and no buttons, used by App.popStatus(message). It does not push onto the modal stack — purely decorative — and is dismissed with app.popStatus(null).

Standalone popups

Confirm.show(app, …​) (and the other Popup.show family methods) expect app to already be running — they call app.modal(p), which pumps drainOnce_() on the existing run loop’s input claim. When you need a popup from a context with no enclosing app — a Hook.onKey handler being the canonical case — use the standalone variants, which push the popup, wire onDismiss to app.quit(), and run the App so key events are routed:

var app = App.new()
if (Confirm.runStandalone(app, "Disconnect... Are you sure?")) {
  // user clicked Yes
}

Standalone helpers are available as Alert.runStandalone(app, msg), Alert.runStandalone(app, title, msg), Confirm.runStandalone(app, msg), and Prompt.runStandalone(app, msg, initial).

For custom popup drivers, onDismiss=(fn) fires from dismissWith_ after the popup pops itself off the modal stack, with the dismissed value (Bool / String / null depending on the popup subclass). If you call app.run() yourself, wire onDismiss to app.quit(); otherwise app.run() would loop forever on an empty modal stack.

Help viewer

A modal scrollable text viewer for context help. The body is parsed once as a markdown subset (see "Markdown subset" below) and laid out lazily — text wraps to the dialog width and reflows on resize.

import "ui" for Help

Help.show(app, "Help — Editor",
          "# Editor\n\n" +
          "- `Up`/`Down`  scroll\n" +
          "- `Enter`     close\n")

Keys: Up, Down, PageUp, PageDown, Home, End scroll; any other key dismisses. Wheel scrolls one line over the text and one page over the scrollbar. Scrollbar clicks jump proportionally; button-1 drags fall through to screen text selection. An ordinary mouse-button click also dismisses, including a click outside the dialog. Same scrollbar knobs as ListView (scrollbarSide=, scrollbarSeparator=). Clicking the frame’s [X] corner button dismisses (inherited from Pane).

App.showHelp (F1 by default) walks from the focused leaf up the parent chain looking for a widget with helpText set, then calls Help.show with the title "Help" and that text. Set helpText on whichever widget is the most-specific scope you want help to fall back to.

Prompts and other modal widgets can also carry helpText. Because the focused input or list is their child, the same parent walk finds the modal’s field-specific help before any help attached to the underlying screen.

Default placement: Help.centeredBounds_() claims the full cterm height minus the status row (y=1, h=screenHeight-1) and a 2-cell horizontal margin (w=screenWidth-4). Pass an explicit Rect via the bounds= setter after construction to override.

Padding: 1-cell margin on every side except the scrollbar column, where text is flush. The scrollbar defaults to the right, and its column plus optional separator span the full inner height (between the top and bottom frame chars); only text rows inset by the top/bottom padding.

Markdown subset

Help body strings (and any helpText) are parsed as a small CommonMark + Pandoc subset by ui_markdown.wren. Supported syntax:

  • Block: # / / # headings; - ` or `* ` bullets; blank-line- separated paragraphs; Pandoc-style definition lists (term line followed by one or more `: description lines).

  • Inline: bold, code . Backslash escapes a literal *, ` , or \.

  • Hard line break: a line ending in two trailing spaces forces a break at that point inside a paragraph or bullet. Continuation uses the same wrap indent as a soft wrap (so a bullet’s hard- break still hangs under the text).

  • Italics are intentionally omitted — terminal italic support varies and dim grey on dark blue is hard to read.

Each block-level token resolves to a style role (help.heading.1, help.bold, help.code, help.bullet, etc.) that cascades through help and finally default, so themes can restyle without touching the parser. Unknown markup falls through as literal text.

Definition lists pad each term to a common column width — capped at half the dialog — and wrap descriptions inside the description column, with continuation lines indented to the description start. Term and description default to help.bold and help.text respectively.

Headings render as a single-row reverse-video bar: blue-on-cyan text with one styled space cell on each end (" Title ").

Layout is reflow-on-width: Markdown.layout(doc, cols) returns a list of MdLine`s, each carrying pre-resolved style runs ready for `Painter.text. Help re-runs layout whenever its bounds change (open, resize, scrollbar appearing/disappearing) and clamps the scroll position accordingly.

Example combining heading + def-list:

# Capture Type

ASCII
:  Plain text, no escape sequences
Raw
:  Preserves ANSI escape sequences
Binary
:  Saves the current screen as IBM-CGA / BinaryText

Theme, Style, Glyphs

A Theme bundles a role → Style map and a Glyphs (name → glyph) lookup. Style is a four-field tuple (font, legacyAttr, fgRgb, bgRgb); any field may be null to inherit from a parent role. Painting picks one of the two color paths based on what the backend supports — RGB on capable terminals, legacy attr otherwise.

Role cascade: Theme.style(role) walks dotted role suffixes, merging partial Styles at each level until every field is populated. "list.item.focused" falls back to "list.item" falls back to "default". "default" is the cascade terminator and must be a complete Style.

Inactive cascade: when a role ends in .inactive, the Theme walks a parallel chain — X.inactive, then parent(X).inactive, …, finally default.inactive. Falls through to default if the theme has no inactive variants at all. Widget.style(role) auto-suffixes .inactive whenever inActiveLayer is false, so a single layer-state flag cascades through frame, title, list rows, input field, button labels, and scrollbar without per-widget bookkeeping.

A widget lands in the inactive layer for either of two reasons:

  • It isn’t part of the App’s modal-top subtree (a modal is on top, and the widget is in the layer behind it).

  • An ancestor that gates focus visibility has focused == false. Widget.gatesActiveLayer defaults to false; Pane overrides it to true, so an unfocused Pane in a multi-pane layout dims every cell it owns AND every descendant’s surface — frame, title, bg fill, list rows all agree on active-vs-inactive without each subclass having to know.

activitySensitive=false opts a widget out of this style selection. It continues to report its computed inActiveLayer value, but style(role) always resolves the base role. This is intended for ambient screen backgrounds, title bars, and status/help bars which do not have active and inactive forms. Do not use focusable=false for this purpose: display-only children such as progress bars still need to inherit an inactive containing Pane.

Theme.default is the C-owned compiled fallback decoded into Wren and memoized so someTheme == Theme.default works as identity equality. Theme builders use it to populate roles and glyphs they do not override, and an unattached Widget falls back to it when it has no Theme in its parent chain. It uses a white-on-blue palette, yellow frame/title, lightbar selection, gray-on-blue scrollbar, and bright-white-on-cyan inactive palette.

Theme.current decodes Host.themeData and caches the result against Host.themeGeneration. App.new() initializes from this process-wide selection. The built-in Classic Theme and file themes use the same host representation; their storage and complete role/glyph registry are documented in Themes.adoc. Scripts may still construct a Theme directly or assign app.theme to replace the program theme for one App. Such a Wren object does not modify the C-owned selection or any other VM.

Glyphs entries are either a single string or [primary, fallback] pairs — primary is the rich Unicode glyph, fallback is an ASCII-safe substitute. Cell storage is CP437, so primary glyphs MUST be in CP437 too — Cell.ch= substitutes ? for any codepoint not in the table. As a safety net, Glyphs[name] auto-promotes to the per-entry fallback when the primary’s first codepoint doesn’t encode in CP437 (probed via the foreign Codepage.encodes_(text)). Resolutions are cached per name so the encoding test runs once. Set theme.glyphs.asciiOnly = true to force the fallback for every entry that has one — useful when the user has selected a font that can’t render line-drawing characters at all.

Glyph families used by the library:

  • frame.display.* — single-line -- borders for informational viewers, plus tee.left/right, title.left/right, and separator.

  • frame.control.* — double-line == borders for panes containing choices, buttons, or editable input. Pane.frameKind selects between the two frame families.

  • scrollbar.track, scrollbar.thumb, scrollbar.up, scrollbar.down, scrollbar.separator — scrollbar pieces.

Painter

Painter is the low-level drawing toolbox. Every primitive takes a Surface as its first argument and mutates the cells in place; no calls reach Screen.writeRect from here. Coordinates inside a Surface are 0-based (0..width-1, 0..height-1); widgets that work in absolute screen coords convert at composition time.

The most useful entry points:

fill(s, rect, ch, style) Bulk-fill a Surface rect with a single character + Style.

text(s, x, y, str, style)
text(s, x, y, str, style, maxW)

Draw str on a single row. 6-arg form caps at maxW cells.

frame(s, rect, glyphs, style)
frame(s, rect, glyphs, style, prefix)

Box around rect using prefix.* glyphs. The default is "frame.display"; pass "frame.control" for a control-bearing pane.

frameTitle(s, rect, glyphs, frameStyle, title, titleStyle, prefix)

Frame with a centered `+

Title

+` in the top border.

hline(s, x, y, len, ch, style)
vline(s, x, y, len, ch, style)

Single-row / single-column lines.

scrollbar(s, x, y, h, scrollTop, total, viewport, glyphs, trackStyle, thumbStyle)

Vertical scrollbar with always-visible up/down arrows on the top/bottom rows when h >= 3. Thumb stays inside the track between the arrows.

scrollbarClick(py, h, total, viewport, current)

Resolve a click at row py (0-based, scrollbar-local) into a new scrollTop. Top/bottom arrow rows step ±1; track clicks jump proportionally.

shadow(s, x0, y0, w, h)

Paint a drop shadow on the cells around the rect: 2 columns wide on the right, 1 row tall on the bottom. Each shadow cell keeps its existing character and dims the attribute (legacy 0x08, RGB fg 0x202020 on bg 0x000000).

applyStyle(cell, style)

Apply a Style to an existing Cell, leaving null fields untouched. Exposed for callers that mutate a cell view directly.

UI Modules

The library is split across one module per topic, plus the convenience aggregator:

ui

Re-exports every public class.

ui_app

App.

ui_widget

Widget, Container, Rect.

ui_pane

Pane.

ui_list

ListView.

ui_input

TextInput, SelectOnFocusInput.

ui_button

Button.

ui_checkbox

Checkbox.

ui_radio

RadioGroup.

ui_spinbox

SpinBox.

ui_statusbar

StatusBar.

ui_menubar

MenuBar.

ui_form

Form.

ui_popup

Popup, Alert, Confirm, Prompt, Find, PopStatus.

ui_help

Help.

ui_draw

Painter.

ui_style

Style, Glyphs, Theme.

Demo gallery

scripts/ui_demo.wren is a playground for the library; run it from the Wren console (Ctrl+`):

import "ui_demo" for UiDemo
UiDemo.run()

Top level is a list of widget demos; pick one with Enter to launch it, Esc returns to the gallery. Each demo runs its own App via runSync (the console blocks the main loop, so doterm can’t dispatch through to a parked fiber); nesting is fine because every App save/restores Screen, mouse events, and CustomCursor on entry and exit.

Worked Example: Auto-respond to a Prompt

A small script that watches inbound text for known prompts and sends canned responses. Hook.onMatch is the right tool for this — it runs a streaming regex against the inbound byte stream and fires the callback on each match, no manual buffering required:

import "syncterm" for Hook, Conn

Hook.onMatch("Press any key to continue") { |m|
  Conn.send("\r")
}

Hook.onMatch("Logon: ") { |m|
  Conn.send("myhandle\r")
}

onMatch is passthrough-only, so the matched text always reaches the terminal — the callback just acts on the side (sending bytes back to the BBS, updating script state, etc.). When a prompt is preceded by colour codes, use Hook.onMatchClean instead so the escapes don’t break the literal match.

onMatch substring-matches anywhere in the stream, not anchored to line boundaries, so prompts that wait for input mid-line (like `Logon: `) work the same as prompts followed by a newline. See Hook Events for the regex grammar accepted, including which constructs aren’t supported (no character classes, no anchors, no backslash escapes).

Worked Example: Per-byte Inspection

Hook.onInput is the lower-level primitive — fires once per inbound byte before the byte reaches the terminal. Use it when you need to react to individual bytes rather than text patterns. Below: count BEL (0x07) bytes received over the session.

import "syncterm" for Hook

class Stats {
  static bells { __bells }
  static bells=(n) { __bells = n }
}
Stats.bells = 0

Hook.onInput { |b|
  if (b == 0x07) Stats.bells = Stats.bells + 1
  return false
}

Inspect the count from the Wren console (Ctrl+`) — Stats.bells returns the running total. Returning false lets the byte through to cterm; returning true would consume it (so the BEL never reaches the terminal and never beeps). Filtered variants — Hook.onInput(0x07, fn) — push the byte equality check to the C side, avoiding a Wren entry on every non-target byte.