feat(core): support streaming fetch responses (#300)
* feat(core): support streaming fetch responses - Add a typed line-streaming Cmd.fetch overload and carry it through the command wire and runtime host. - Keep stream lifecycle deterministic with loud cancellation and duplicate-key rejection. - Cover the feature with contract, conformance, runtime, compiled-core, harness, example, and documentation updates. * feat: stream AI chat through Vercel gateway - Render chat-completion SSE deltas as they arrive. - Pin the example to Vercel AI Gateway with official key config. - Cover streaming, failure, and replay paths end to end. * feat: add streaming fetch and chatbot example * feat(chatbot): refine streaming chat experience - Add a compact live model picker and immediate Stop action. - Improve conversation layout, prompt focus, and caret retention. - Expand chatbot documentation and end-to-end regression coverage. * fix: harden streaming fetch limits * fix: harden streaming fetch and textarea behavior * fix(canvas): render lifted rich text
This commit is contained in:
@@ -93,11 +93,11 @@ Read the full guide at [native-sdk.dev/quick-start](https://native-sdk.dev/quick
|
||||
|
||||
## Examples
|
||||
|
||||
The apps pictured above live in [examples/](./examples), most as zero-config projects — `app.zon` plus `src/`, no build files — run straight from their directory with `native dev`. Start with the TypeScript examples when learning the primary authoring path. Their `-ts` suffix is historical: `soundboard-ts` and `system-monitor-ts` are ports kept beside older Zig originals, while `ai-chat-ts` is TypeScript-only. New apps created by `native init` use TypeScript without a suffix.
|
||||
The apps pictured above live in [examples/](./examples), most as zero-config projects — `app.zon` plus `src/`, no build files — run straight from their directory with `native dev`. Start with the TypeScript examples when learning the primary authoring path. The `-ts` suffix on `soundboard-ts` and `system-monitor-ts` is historical because those apps are ports kept beside older Zig originals. Chatbot is TypeScript-only and follows the unsuffixed naming used by new apps created with `native init`.
|
||||
|
||||
| Example | What it shows |
|
||||
| --- | --- |
|
||||
| [`ai-chat-ts`](./examples/ai-chat-ts) | TypeScript + Native markup end to end: modules, a text editor, fetch effects, and replay-safe configuration. |
|
||||
| [`chatbot`](./examples/chatbot) | TypeScript + Native markup end to end: modules, a text editor, streaming fetch effects, and replay-safe configuration. |
|
||||
| [`soundboard-ts`](./examples/soundboard-ts) | The full music-player showcase in TypeScript + Native markup: audio, search, assets, timers, and context menus. |
|
||||
| [`system-monitor-ts`](./examples/system-monitor-ts) | A live process monitor in TypeScript + Native markup: subprocess effects, tables, charts, and timers. |
|
||||
| [`calculator`](./examples/calculator) | A complete small app: markup keypad, keyboard input, chrome shortcuts, theming. |
|
||||
|
||||
@@ -3171,18 +3171,18 @@ fn tsCoreE2eArtifact(
|
||||
const scaffold_ide_mod = module(b, target, optimize, "tests/ts-core/scaffold_ide_e2e_tests.zig");
|
||||
scaffold_ide_mod.addImport("tooling", tooling_mod);
|
||||
|
||||
// The ai-chat-ts example's core and markup, tested the same way:
|
||||
// The Chatbot example's core and markup, tested the same way:
|
||||
// the chat client for an OpenAI-compatible endpoint, driven through
|
||||
// the fake fetch feed (no network) with its shipping markup.
|
||||
const ai_chat_fixture = externalCoreFixtureModule(b, target, optimize, node, corewire_exe, .{
|
||||
.entry = "examples/ai-chat-ts/src/core.ts",
|
||||
.src_dir = b.path("examples/ai-chat-ts/src"),
|
||||
.entry = "examples/chatbot/src/core.ts",
|
||||
.src_dir = b.path("examples/chatbot/src"),
|
||||
.name = "ai_chat_core",
|
||||
});
|
||||
const ai_chat_core_mod = ai_chat_fixture.module;
|
||||
const ai_chat_stage = b.addWriteFiles();
|
||||
const ai_chat_root = ai_chat_stage.addCopyFile(b.path("tests/ts-core/ai_chat_e2e_tests.zig"), "ai_chat_e2e_tests.zig");
|
||||
_ = ai_chat_stage.addCopyFile(b.path("examples/ai-chat-ts/src/app.native"), "app.native");
|
||||
_ = ai_chat_stage.addCopyFile(b.path("examples/chatbot/src/app.native"), "app.native");
|
||||
const ai_chat_mod = b.createModule(.{
|
||||
.root_source_file = ai_chat_root,
|
||||
.target = target,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
# Input Group
|
||||
|
||||
The composer shape: one bordered field wrapping a [textarea](/docs/components/textarea) plus an accessory row of controls inside the same border — attach on the bottom-left, send on the bottom-right. The group wears the focus ring whenever focus is on any control inside it, and the textarea's own chrome dissolves automatically, so the whole group reads as a single field. The textarea keeps its full behavior: `text` and `placeholder` bind from the model, `on-input` hears every edit, `on-submit` rides the primary chord, and `autofocus` lands the keyboard on mount.
|
||||
The composer shape: one bordered field wrapping a [textarea](/docs/components/textarea) plus an accessory row of controls inside the same border — attach on the bottom-left, send on the bottom-right. The group wears the focus ring whenever focus is on any control inside it, and the textarea's own chrome dissolves automatically, so the whole group reads as a single field. The textarea keeps its full behavior: `text` and `placeholder` bind from the model, `on-input` hears every edit, `on-submit` handles submission, `submit-on-enter="true"` opts a chat composer into plain-Enter submission, and `autofocus` lands the keyboard on mount.
|
||||
|
||||
<ComponentPreview name="input-group" alt="An input group rendered by the engine" />
|
||||
|
||||
@@ -13,7 +13,7 @@ The textarea comes first (document order is focus order), then the optional `inp
|
||||
|
||||
```html
|
||||
<input-group label="Message composer" height="120">
|
||||
<textarea placeholder="Type a message" text="{draft}" on-input="draft_edited" on-submit="send" />
|
||||
<textarea placeholder="Type a message" text="{draft}" submit-on-enter="true" on-input="draft_edited" on-submit="send" />
|
||||
<input-group-actions>
|
||||
<button icon="plus" variant="ghost" size="icon" on-press="attach" label="Attach"></button>
|
||||
<spacer grow="1" />
|
||||
@@ -33,6 +33,7 @@ ui.inputGroup(.{
|
||||
}, ui.el(.textarea, .{
|
||||
.placeholder = "Type a message",
|
||||
.text = model.draft,
|
||||
.submit_on_enter = true,
|
||||
.on_input = Ui.inputMsg(.draft_edited),
|
||||
.on_submit = .send,
|
||||
.semantics = .{ .label = "Message" },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
# Textarea
|
||||
|
||||
Multi-line text entry. Like [input](/docs/components/input), `text` and `placeholder` bind from the model and `on-input` names a Msg variant that receives every edit as a text-input event — see [input](/docs/components/input) for the core-side contract in both languages. Enter (and Shift+Enter) inserts a newline instead of submitting; when a textarea carries `on-submit`, the submit rides the primary chord — Cmd+Enter on macOS, Ctrl+Enter elsewhere. Give it a definite `width` and `height` (or a `grow`) to size the editing box.
|
||||
Multi-line text entry. Like [input](/docs/components/input), `text` and `placeholder` bind from the model and `on-input` names a Msg variant that receives every edit as a text-input event — see [input](/docs/components/input) for the core-side contract in both languages. By default, Enter (and Shift+Enter) inserts a newline; when a textarea carries `on-submit`, submission rides Cmd+Enter on macOS or Ctrl+Enter elsewhere. Chat composers can set `submit-on-enter="true"`: plain Enter then submits, Shift+Enter still inserts a newline, and the primary chord still submits. Give it a definite `width` and `height` (or a `grow`) to size the editing box.
|
||||
|
||||
<ComponentPreview name="textarea" alt="A textarea rendered by the engine" />
|
||||
|
||||
@@ -35,6 +35,7 @@ ui.el(.textarea, .{
|
||||
"placeholder",
|
||||
"disabled",
|
||||
"autofocus",
|
||||
"submit-on-enter",
|
||||
"on-input",
|
||||
"on-submit",
|
||||
]}
|
||||
|
||||
@@ -92,7 +92,7 @@ A few widget kinds are deliberately **not** markup elements because their shape
|
||||
|
||||
Apps with their own iconography can parse any stroke-dialect/Feather/Tabler-dialect SVG at comptime (`canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg"))`) and register it at boot with `canvas.icons.registerAppIcons(&table)`: the draw paths (icon leaves via `ui.appIcon`, `ElementOptions.icon` on buttons, toggle buttons, icon buttons, and list/menu items) resolve registered names exactly like built-ins. Markup reaches them through the `app:` NAMESPACE (`<icon name="app:waveform"/>`, `icon="app:waveform"`): bare names keep the closed built-in vocabulary (the compiled engine proves them at comptime, where a runtime registration cannot exist), while `app:` names are structurally accepted by both engines and verified by `native check` against the model contract - declare the table as `pub const app_icons` on the app root so the contract emit reflects the same names `main` registers. Bound icon names (`icon="{binding}"`) make the choice model data - a per-row status icon, a play/pause toggle - and any name that fails to resolve at draw time renders the missing-icon fallback (a slashed circle) with a Debug warning naming the value, never a silent gap.
|
||||
|
||||
Layout attributes: `gap`, `padding`, `grow`, `width`, `height`, `wrap`, `text-alignment`, `columns`, `main`, `cross`, `virtualized`, and the anchored-floating family on `dropdown-menu` and `tooltip`: `anchor` (`below`/`above` — floats the surface against its parent, flipping when the preferred side does not fit), `anchor-alignment` (`start`/`end`/`stretch`), `anchor-offset` (points, default 4), plus `tooltip-delay` on `tooltip` alone (the hover-intent show delay in milliseconds, default the 600ms token; `"0"` shows the instant the trigger is hovered; keyboard-focus reveals are always immediate — a teaching error without `anchor` beside it). An anchored tooltip's visibility is runtime-owned hover intent on its trigger, unlike the model-owned dropdown. `gap` belongs to flow containers: the stacking kinds (`stack`, `panel`, `card`, and the surface/modal elements) layer their children, so `gap` there is rejected with a teaching error — wrap the children in a `column` (or `row`) inside for flow (on `split` it sets the divider band thickness). `width` and `height` are definite sizes: the element is exactly that size, so intrinsic content neither shrinks nor silently overflows it (`resizable` treats `width` as its initial width), and debug builds log a `zero_canvas_layout` diagnostic whenever children's minimum sizes overflow their container. `min-width` is a floor without the definite max: the element may grow past it but never shrink below — on split panes it is what bounds the divider drag. `wrap` applies to `text` only: `wrap="true"` word-wraps the content at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are the honest single-line mode — the content measures and paints as one line, and content that does not fit follows `overflow`. `overflow` (`text` only) names the single-line policy for content that does not fit: `ellipsis` (the default) elides the tail behind a trailing … measured with the same metrics paint uses, the right choice for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column, where "1…" would be worse than a partial glyph. There is deliberately no overflow-visible — painting past the frame is the bug class the layout audit exists to catch. `text-alignment` (`start`|`center`|`end`) aligns text content in text leaves, status bars, and surface titles. `columns` fixes a `grid`'s column count (grid-only — anywhere else it is a teaching error; omit it for the derived near-square grid). `tree-level` gives flat sibling rows with `role="treeitem"` a one-based logical depth so Left/Right can resolve parents and children; omit it for structurally nested rows. Appearance: `variant`, `size` (the control scale `default`|`sm`|`lg`|`icon` on every sized element; on `text` also the typography rungs `heading`|`display` — named typography token steps for section headings and hero stats, themable like every token, and text-only: on a control they are a teaching error naming text as their home; numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `expanded` (tree rows: model-owned disclosure state; omit on leaves). Focus: `autofocus` (focusable controls only) moves keyboard focus to the element when it mounts or when the bound value turns on — edge-triggered, so holding it true never re-steals focus; it is the model-driven way to focus an editor on create or give a keyboard-first app its first focus. Semantics: `role` (`treeitem` also makes a row part of its tree's roving focus set), `label` (an explicit accessible name — it replaces the element's text; see [Accessibility](#accessibility)). Identity: `key` (sibling-scoped) and `global-key` (survives moving between containers — board cards, tab pages). Window chrome: `window-drag="true"` marks the element as a window-drag surface for hidden-titlebar windows (see [Hidden titlebar](#hidden-titlebar-drag-regions-and-chrome-insets)).
|
||||
Layout attributes: `gap`, `padding`, `grow`, `width`, `height`, `min-width`, `max-width`, `wrap`, `text-alignment`, `columns`, `main`, `cross`, `virtualized`, and the anchored-floating family on `dropdown-menu` and `tooltip`: `anchor` (`below`/`above` — floats the surface against its parent, flipping when the preferred side does not fit), `anchor-alignment` (`start`/`end`/`stretch`), `anchor-offset` (points, default 4), plus `tooltip-delay` on `tooltip` alone (the hover-intent show delay in milliseconds, default the 600ms token; `"0"` shows the instant the trigger is hovered; keyboard-focus reveals are always immediate — a teaching error without `anchor` beside it). An anchored tooltip's visibility is runtime-owned hover intent on its trigger, unlike the model-owned dropdown. `gap` belongs to flow containers: the stacking kinds (`stack`, `panel`, `card`, and the surface/modal elements) layer their children, so `gap` there is rejected with a teaching error — wrap the children in a `column` (or `row`) inside for flow (on `split` it sets the divider band thickness). `width` and `height` are definite sizes: the element is exactly that size, so intrinsic content neither shrinks nor silently overflows it (`resizable` treats `width` as its initial width), and debug builds log a `zero_canvas_layout` diagnostic whenever children's minimum sizes overflow their container. `min-width` is a floor without the definite max: the element may grow past it but never shrink below — on split panes it is what bounds the divider drag. `max-width` is the inverse ceiling without a definite minimum, so the element still shrinks with a narrow parent; a growing child inside a centered row is the responsive content-column pattern. `wrap` applies to `text` only: `wrap="true"` word-wraps the content at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are the honest single-line mode — the content measures and paints as one line, and content that does not fit follows `overflow`. `overflow` (`text` only) names the single-line policy for content that does not fit: `ellipsis` (the default) elides the tail behind a trailing … measured with the same metrics paint uses, the right choice for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column, where "1…" would be worse than a partial glyph. There is deliberately no overflow-visible — painting past the frame is the bug class the layout audit exists to catch. `text-alignment` (`start`|`center`|`end`) aligns text content in text leaves, status bars, and surface titles. `columns` fixes a `grid`'s column count (grid-only — anywhere else it is a teaching error; omit it for the derived near-square grid). `tree-level` gives flat sibling rows with `role="treeitem"` a one-based logical depth so Left/Right can resolve parents and children; omit it for structurally nested rows. Appearance: `variant`, `size` (the control scale `default`|`sm`|`lg`|`icon` on every sized element; on `text` also the typography rungs `heading`|`display` — named typography token steps for section headings and hero stats, themable like every token, and text-only: on a control they are a teaching error naming text as their home; numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `expanded` (tree rows: model-owned disclosure state; omit on leaves). Focus: `autofocus` (focusable controls only) moves keyboard focus to the element when it mounts or when the bound value turns on — edge-triggered, so holding it true never re-steals focus; it is the model-driven way to focus an editor on create or give a keyboard-first app its first focus. Semantics: `role` (`treeitem` also makes a row part of its tree's roving focus set), `label` (an explicit accessible name — it replaces the element's text; see [Accessibility](#accessibility)). Identity: `key` (sibling-scoped) and `global-key` (survives moving between containers — board cards, tab pages). Window chrome: `window-drag="true"` marks the element as a window-drag surface for hidden-titlebar windows (see [Hidden titlebar](#hidden-titlebar-drag-regions-and-chrome-insets)).
|
||||
|
||||
## Styling with design tokens
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ The question behind "can I use npm?" is almost always one of these four:
|
||||
|
||||
## Calling APIs, AI endpoints included
|
||||
|
||||
Most packages people reach for first — API clients, AI SDKs — are HTTP wrappers. The HTTP is already in the toolkit: `Cmd.fetch` performs a buffered exchange on the effect engine and routes the result back as an ordinary Msg carrying `{ status, body }`. The request is data, the response is a message, and a recorded session replays the whole conversation with zero network — which is not something an SDK dependency can offer. A complete client for an OpenAI-compatible chat endpoint:
|
||||
Most packages people reach for first — API clients, AI SDKs — are HTTP wrappers. The HTTP is already in the toolkit: `Cmd.fetch` can perform a buffered exchange and route `{ status, body }`, or line-stream an SSE/NDJSON response through repeated Msgs. The request is data, every response event is a message, and a recorded session replays the whole conversation with zero network — which is not something an SDK dependency can offer. A complete buffered client for an OpenAI-compatible chat endpoint:
|
||||
|
||||
<CodeToggle>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
case "answered":
|
||||
// The status is the real HTTP status - a 404 is a delivered
|
||||
// response. Parse the body in pure TypeScript over bytes; the
|
||||
// ai-chat-ts example ships the complete JSON walk.
|
||||
// The Chatbot example ships the complete JSON walk.
|
||||
return { ...model, waiting: false, answer: msg.body };
|
||||
case "ask_failed":
|
||||
// The transport reason ("timed_out", "connect_failed", ...) -
|
||||
@@ -108,7 +108,25 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
|
||||
</CodeToggle>
|
||||
|
||||
The flagship version of this pattern is [`examples/ai-chat-ts`](https://github.com/vercel-labs/native/tree/main/examples/ai-chat-ts): a chat client for an OpenAI-compatible endpoint — conversation history in the Model, request encoding and response parsing as plain subset TypeScript over bytes, endpoint and credentials through the env channel with the key riding a runtime-built `Authorization: Bearer` header (header names are compile-time; header values may be runtime bytes), honest sending/failed/unconfigured states — with an end-to-end suite that pins the exact request bytes and replays a recorded conversation with no network in the room and none of the launch variables set. One v1 boundary, stated plainly: responses are buffered, not token-streamed (the engine underneath already streams line-framed bodies on the Zig channel; the TS Cmd surface for it is roadmap).
|
||||
For token-by-token UI, request the endpoint's streaming mode and add a `line` route:
|
||||
|
||||
```ts
|
||||
Cmd.fetch(
|
||||
{
|
||||
url: endpoint,
|
||||
method: "POST",
|
||||
headers: { accept: "text/event-stream", authorization: bearerToken },
|
||||
body: requestBody,
|
||||
timeoutMs: 120000,
|
||||
maxLineBytes: 65536,
|
||||
},
|
||||
{ key: "chat", line: "chat_event", ok: "chat_done", err: "chat_failed" },
|
||||
)
|
||||
```
|
||||
|
||||
`chat_event` carries one `Uint8Array` field for each complete SSE/NDJSON line; parse its `data:` payload and append the delta to the assistant message in the Model. `chat_done` carries one number field with the terminal HTTP status. Cancellation and transport failures reach `chat_failed` as reason bytes, including `cancelled`, so a partially displayed answer never ends silently.
|
||||
|
||||
The flagship [`examples/chatbot`](https://github.com/vercel-labs/native/tree/main/examples/chatbot) uses that streaming shape against Vercel AI Gateway: the Gateway URL and `openai/gpt-5.6-luna` default are fixed, a dropdown inside the prompt group lists the Luna, Terra, and Sol variants in that order, `AI_GATEWAY_API_KEY` and an optional initial `NATIVE_SDK_CHAT_MODEL` override arrive through the env channel, and every `choices[0].delta.content` extends the visible pending assistant reply before `[DONE]` and the terminal status commit it to history. Its end-to-end suite pins the request, observes partial UI updates, and replays every stream line without network or launch variables.
|
||||
|
||||
## Full npm ecosystem UIs
|
||||
|
||||
@@ -150,6 +168,6 @@ import { containsIgnoreCase } from "@native-sdk/core/text"; // the SDK library c
|
||||
```
|
||||
|
||||
- **Vendor it under `src/`.** Subset-clean TypeScript compiles into the core like your own modules ([splitting a core into modules](/docs/typescript#splitting-a-core-into-modules)); the subset checker tells you immediately — by rule ID, with the rewrite — whether a vendored file fits. Code that leans on classes, exceptions, or regexes generally wants rewriting rather than vendoring, and the rewrite is usually smaller than the dependency.
|
||||
- **`@native-sdk/core/*` is the curated library channel**: SDK modules written in the same subset, compiled into your core when imported and absent when not. Today that is `@native-sdk/core/text` — the byte-splice text engine (caret, selection, IME, case-insensitive search) — and `@native-sdk/core/events` — the canonical event record types markup and the wiring channels match. The channel grows with the toolkit; JSON encoding/parsing over bytes, today demonstrated in `examples/ai-chat-ts/src/api.ts`, is the kind of module it exists to absorb.
|
||||
- **`@native-sdk/core/*` is the curated library channel**: SDK modules written in the same subset, compiled into your core when imported and absent when not. Today that is `@native-sdk/core/text` — the byte-splice text engine (caret, selection, IME, case-insensitive search) — and `@native-sdk/core/events` — the canonical event record types markup and the wiring channels match. The channel grows with the toolkit; JSON encoding/parsing over bytes, today demonstrated in `examples/chatbot/src/api.ts`, is the kind of module it exists to absorb.
|
||||
|
||||
One thing deliberately does not exist: a package manager for cores. A core's import graph is exactly the files under `src/` plus the SDK modules — the whole program is readable, the build is hermetic, and nothing arrives at build time that you have not checked in.
|
||||
|
||||
@@ -282,6 +282,10 @@ The runtime interprets the command after the model commits and dispatches any re
|
||||
<td><code>Cmd.fetch(spec, { key?, ok, err })</code></td>
|
||||
<td>A buffered HTTP(S) exchange; <code>ok</code> carries <code>{ status, body }</code> (a 404 is still <code>ok</code> — a delivered response), <code>err</code> the transport reason</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.fetch(spec, { key?, line, ok, err })</code></td>
|
||||
<td>A line-streamed HTTP(S) exchange for SSE/NDJSON; each <code>line</code> carries bytes as it arrives, then <code>ok</code> carries the terminal HTTP status or <code>err</code> the transport reason</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.clipboardWrite(bytes)</code> / <code>Cmd.clipboardRead({ key?, ok, err })</code></td>
|
||||
<td>System clipboard: write is fire-and-forget, read routes the text bytes back</td>
|
||||
@@ -325,7 +329,7 @@ The runtime interprets the command after the model commits and dispatches any re
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Result arms are ordinary Msg arms with the shape the effect produces — one `Uint8Array` field for host results and errors, one number field for timer fires, no fields for `writeFile`'s ok, one number plus one `Uint8Array` field for `fetch`'s — and tsc checks the shapes for you. Keys carry ONE in-flight discipline: a keyed effect — `Cmd.request`, the named engine ops, `Cmd.delay` — whose key is already in flight replaces the old one (the superseded result is dropped, no message; the debounce shape), and `Cmd.cancel` drops it silently. The one exception is a live `Cmd.spawn` key, which rejects the duplicate (`err` gets `rejected`) — a running subprocess is never killed implicitly; cancel it first, and that cancel is loud (`err` gets `cancelled`) because killing a process is an observable event. Every `err` arm receives a machine-readable reason, so failure is never silence. Persistence today is `Cmd.writeFile` + a boot-time `Cmd.readFile` — the pattern every real app uses.
|
||||
Result arms are ordinary Msg arms with the shape the effect produces — one `Uint8Array` field for host results and errors, one number field for timer fires and a streaming fetch's terminal status, no fields for `writeFile`'s ok, one number plus one `Uint8Array` field for a buffered fetch's result — and tsc checks the shapes for you. Keys carry ONE in-flight discipline: a keyed effect — `Cmd.request`, buffered named engine ops, `Cmd.delay` — whose key is already in flight replaces the old one (the superseded result is dropped, no message; the debounce shape), and `Cmd.cancel` drops it silently. Live `Cmd.spawn` and streaming-fetch keys reject a duplicate (`err` gets `rejected`) so two sources can never splice into one stream; cancel either first, and that cancel is loud (`err` gets `cancelled`). A streaming fetch whose line is cut or dropped also ends loudly with `err: truncated`, never a misleading successful status. Every `err` arm receives a machine-readable reason, so failure is never silence. Persistence today is `Cmd.writeFile` + a boot-time `Cmd.readFile` — the pattern every real app uses.
|
||||
|
||||
For durable packaged-app paths, request the framework-provided app data directory through `envMsgs` and join filenames below it. The runner synthesizes `NATIVE_SDK_APP_DATA_DIR` from the platform convention (`~/Library/Application Support/<app>` on macOS and `%LOCALAPPDATA%\<app>\Data` on Windows), then dispatches it as the same journaled one-`Uint8Array`-field message as an ambient value. This keeps a Finder- or Explorer-launched app independent of its process working directory.
|
||||
|
||||
@@ -480,7 +484,7 @@ export function subscriptions(model: Model): Sub<Msg> {
|
||||
}
|
||||
```
|
||||
|
||||
Keep the Sub-vs-stream line straight: a Sub is declarative — derived from the model, started and stopped by reconciliation, never opened or closed by the app. The multi-result streams (`Cmd.spawn`'s lines, `Cmd.audioPlay`'s events, `Cmd.channelOpen`'s posts, and audio capture chunks) are Cmd-initiated — imperative opens with a keyed lifecycle the app drives. If the effect should exist exactly while some model state holds, it wants a Sub; if the app decides when it starts and ends, it is a stream.
|
||||
Keep the Sub-vs-stream line straight: a Sub is declarative — derived from the model, started and stopped by reconciliation, never opened or closed by the app. The multi-result streams (`Cmd.fetch`'s response lines, `Cmd.spawn`'s stdout lines, `Cmd.audioPlay`'s events, `Cmd.channelOpen`'s posts, and audio capture chunks) are Cmd-initiated — imperative opens with a keyed lifecycle the app drives. If the effect should exist exactly while some model state holds, it wants a Sub; if the app decides when it starts and ends, it is a stream.
|
||||
|
||||
## Text input from markup
|
||||
|
||||
|
||||
@@ -412,6 +412,10 @@
|
||||
"name": "min-width",
|
||||
"doc": "Width floor (plain number) without width's definite max: the element may grow past it but never shrink below. On split panes it bounds the divider drag."
|
||||
},
|
||||
{
|
||||
"name": "max-width",
|
||||
"doc": "Width ceiling (plain number) without width's definite min: the element still shrinks with a narrow parent. Use a growing child inside a centered row for a responsive content column."
|
||||
},
|
||||
{
|
||||
"name": "expanded",
|
||||
"doc": "Tree rows (role=\"treeitem\"): disclosure state (true/false or a {binding}). Omit on leaves; expanded rows collapse on Left, collapsed ones expand on Right, both through on-toggle - the model owns the state."
|
||||
@@ -428,6 +432,10 @@
|
||||
"name": "autofocus",
|
||||
"doc": "Focusable controls only: moves keyboard focus to the element when it mounts or when the value turns on (edge-triggered - holding it true never re-steals focus). The TEA way to focus an editor on create."
|
||||
},
|
||||
{
|
||||
"name": "submit-on-enter",
|
||||
"doc": "textarea only: true makes plain Enter dispatch on-submit while Shift+Enter inserts a newline; Cmd/Ctrl+Enter still submits. False or absent keeps the multiline default where Enter inserts and submission uses the primary chord."
|
||||
},
|
||||
{
|
||||
"name": "icon",
|
||||
"doc": "button, toggle-button, list-item, menu-item: vector icon drawn inline (buttons/toggle-buttons before the label, list/menu items as a leading slot): a built-in name (comptime-validated against canvas.icons.known_icon_names, e.g. save, plus, refresh-cw), an app-registered app:<name>, or one {binding} resolving to such a name. Icon-only buttons when the content is empty — add a label. One hit target, one enabled/disabled tint."
|
||||
|
||||
@@ -20,6 +20,7 @@ pub const Op = union(enum) {
|
||||
read_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
|
||||
write_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8, bytes: []const u8 },
|
||||
fetch: Fetch,
|
||||
fetch_stream: FetchStream,
|
||||
clip_write: struct { bytes: []const u8 },
|
||||
clip_read: struct { key: []const u8, ok_tag: u8, err_tag: u8 },
|
||||
delay: struct { key: []const u8, after_ms: f64, msg_tag: u8 },
|
||||
@@ -70,6 +71,21 @@ pub const Op = union(enum) {
|
||||
body: []const u8,
|
||||
};
|
||||
|
||||
pub const FetchStream = struct {
|
||||
key: []const u8,
|
||||
line_tag: u8,
|
||||
ok_tag: u8,
|
||||
err_tag: u8,
|
||||
method: u8,
|
||||
timeout_ms: u32,
|
||||
max_line_bytes: u32,
|
||||
url: []const u8,
|
||||
header_count: u8,
|
||||
/// Raw header block: per header [name_len u8][name][value_len u32 LE][value].
|
||||
header_bytes: []const u8,
|
||||
body: []const u8,
|
||||
};
|
||||
|
||||
pub const Spawn = struct {
|
||||
key: []const u8,
|
||||
/// 0xFF = no line routing (rt.spawn_no_line_tag).
|
||||
@@ -381,6 +397,45 @@ pub const CmdIter = struct {
|
||||
off += 8;
|
||||
break :blk .{ .audio_capture_stop = .{ .key = key } };
|
||||
},
|
||||
// fetch_stream [op 0x20][key][line/ok/err tags][method]
|
||||
// [timeout u32 LE][max line u32 LE][url][headers][body]
|
||||
// (ts_core_host.zig, 0x20).
|
||||
0x20 => blk: {
|
||||
const key = shortBytes(b, &off);
|
||||
const line_tag = b[off];
|
||||
const ok_tag = b[off + 1];
|
||||
const err_tag = b[off + 2];
|
||||
const method = b[off + 3];
|
||||
off += 4;
|
||||
const timeout = std.mem.readInt(u32, b[off..][0..4], .little);
|
||||
off += 4;
|
||||
const max_line_bytes = std.mem.readInt(u32, b[off..][0..4], .little);
|
||||
off += 4;
|
||||
const url = longBytes(b, &off);
|
||||
const header_count = b[off];
|
||||
off += 1;
|
||||
const headers_start = off;
|
||||
var h: usize = 0;
|
||||
while (h < header_count) : (h += 1) {
|
||||
_ = shortBytes(b, &off);
|
||||
_ = longBytes(b, &off);
|
||||
}
|
||||
const header_bytes = b[headers_start..off];
|
||||
const body = longBytes(b, &off);
|
||||
break :blk .{ .fetch_stream = .{
|
||||
.key = key,
|
||||
.line_tag = line_tag,
|
||||
.ok_tag = ok_tag,
|
||||
.err_tag = err_tag,
|
||||
.method = method,
|
||||
.timeout_ms = timeout,
|
||||
.max_line_bytes = max_line_bytes,
|
||||
.url = url,
|
||||
.header_count = header_count,
|
||||
.header_bytes = header_bytes,
|
||||
.body = body,
|
||||
} };
|
||||
},
|
||||
else => std.debug.panic("cmdview: unknown op byte 0x{X:0>2} at offset {d}", .{ op, self.off }),
|
||||
};
|
||||
self.off = off;
|
||||
@@ -682,3 +737,37 @@ test "the pty records decode, alone and inside a batch" {
|
||||
try std.testing.expectEqual(@as(u8, 7), tail.now.msg_tag);
|
||||
try std.testing.expectEqual(@as(?Op, null), iter.next());
|
||||
}
|
||||
|
||||
test "streaming fetch decodes its routes and limits" {
|
||||
const a = std.testing.allocator;
|
||||
var bytes: std.ArrayList(u8) = .empty;
|
||||
defer bytes.deinit(a);
|
||||
|
||||
try bytes.append(a, 0x20);
|
||||
try bytes.append(a, 4);
|
||||
try bytes.appendSlice(a, "chat");
|
||||
try bytes.appendSlice(a, &.{ 7, 8, 9, 1 });
|
||||
try bytes.appendSlice(a, &.{ 0x88, 0x13, 0, 0 }); // 5000 ms
|
||||
try bytes.appendSlice(a, &.{ 0, 0x20, 0, 0 }); // 8192 bytes
|
||||
try bytes.appendSlice(a, &.{ 15, 0, 0, 0 });
|
||||
try bytes.appendSlice(a, "https://ai.test");
|
||||
try bytes.append(a, 1);
|
||||
try bytes.append(a, 6);
|
||||
try bytes.appendSlice(a, "accept");
|
||||
try bytes.appendSlice(a, &.{ 17, 0, 0, 0 });
|
||||
try bytes.appendSlice(a, "text/event-stream");
|
||||
try bytes.appendSlice(a, &.{ 2, 0, 0, 0 });
|
||||
try bytes.appendSlice(a, "{}");
|
||||
|
||||
const stream = findOp(bytes.items, .fetch_stream) orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqualStrings("chat", stream.key);
|
||||
try std.testing.expectEqual(@as(u8, 7), stream.line_tag);
|
||||
try std.testing.expectEqual(@as(u8, 8), stream.ok_tag);
|
||||
try std.testing.expectEqual(@as(u8, 9), stream.err_tag);
|
||||
try std.testing.expectEqual(@as(u8, 1), stream.method);
|
||||
try std.testing.expectEqual(@as(u32, 5000), stream.timeout_ms);
|
||||
try std.testing.expectEqual(@as(u32, 8192), stream.max_line_bytes);
|
||||
try std.testing.expectEqualStrings("https://ai.test", stream.url);
|
||||
try std.testing.expectEqual(@as(u8, 1), stream.header_count);
|
||||
try std.testing.expectEqualStrings("{}", stream.body);
|
||||
}
|
||||
|
||||
+3
-3
@@ -16,11 +16,11 @@ TypeScript is the primary app-authoring language. A new `native init my_app` pro
|
||||
|
||||
| Example | Shows |
|
||||
| --- | --- |
|
||||
| `ai-chat-ts` | Multi-module TypeScript core, text editing, `Cmd.fetch`, environment messages, and deterministic replay. |
|
||||
| `chatbot` | Multi-module TypeScript core, text editing, streaming `Cmd.fetch`, environment messages, and deterministic replay. |
|
||||
| `soundboard-ts` | Full music player: audio effects, timers, search, assets, native context menus, and adaptive markup. |
|
||||
| `system-monitor-ts` | Subprocess effects, timers, parsing, tables, charts, controlled scroll, and confirmation flows. |
|
||||
|
||||
The `-ts` suffix is historical: `soundboard-ts` and `system-monitor-ts` distinguish ports from older Zig originals in the same catalog, while `ai-chat-ts` was introduced as a TypeScript-only example. It is not a template convention: new TypeScript apps need no suffix because TypeScript is the default. Many unsuffixed showcase apps predate that default and still use `src/main.zig`; use them for their feature or visual patterns, not as evidence that new app logic should be Zig.
|
||||
The `-ts` suffix is historical: `soundboard-ts` and `system-monitor-ts` distinguish ports from older Zig originals in the same catalog. Chatbot was introduced as a TypeScript-only example and follows the unsuffixed default. New TypeScript apps need no suffix because TypeScript is the default. Many unsuffixed showcase apps predate that default and still use `src/main.zig`; use them for their feature or visual patterns, not as evidence that new app logic should be Zig.
|
||||
|
||||
## Earlier native-rendered showcase apps (Zig cores)
|
||||
|
||||
@@ -60,4 +60,4 @@ The `-ts` suffix is historical: `soundboard-ts` and `system-monitor-ts` distingu
|
||||
|
||||
`mobile-shell`, `ios`, and `android` are mobile host projects (Xcode/Gradle shells plus shared `app.zon` metadata) rather than desktop app directories.
|
||||
|
||||
Start with `native init` for a small TypeScript + Native markup app, then use `ai-chat-ts`, `soundboard-ts`, or `system-monitor-ts` according to the feature you need. Use `habits` when you specifically want the smallest Zig-core equivalent, `hello` for the lower-level WebView path, `webview` for native commands or WebView policy, `capabilities` for guarded OS services, and the GPU trio for custom-rendered or retained-canvas panes.
|
||||
Start with `native init` for a small TypeScript + Native markup app, then use `chatbot`, `soundboard-ts`, or `system-monitor-ts` according to the feature you need. Use `habits` when you specifically want the smallest Zig-core equivalent, `hello` for the lower-level WebView path, `webview` for native commands or WebView policy, `capabilities` for guarded OS services, and the GPU trio for custom-rendered or retained-canvas panes.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Native SDK ai-chat-ts example
|
||||
|
||||
A chat client for an OpenAI-compatible chat-completions endpoint, authored entirely in **TypeScript + Native markup**. Zero Zig: the logic tier is the app-core subset under `src/`, compiled to native code at build time; `src/app.native` is the whole view tier and `app.zon` the manifest. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
|
||||
|
||||
This is the reference answer to "can a TypeScript core call an AI API?": the network surface is one `Cmd.fetch` with a real `Authorization: Bearer <key>` header built at runtime from the launch environment, the JSON wire format is pure byte math in the subset, and because the whole exchange is effect data, a recorded conversation **replays byte-identically with zero network and zero env reads** — the e2e suite pins the exact request bytes and replays a two-turn conversation, transport failure and retry included, with no endpoint in the room and none of the launch variables set.
|
||||
|
||||
The core is two modules plus one SDK library:
|
||||
|
||||
- `src/core.ts` — the entry module: Model (the conversation, the composer, the request phase, the launch configuration), Msg, update, the env channel, and every exported binding helper.
|
||||
- `src/api.ts` — the chat-completions wire format over bytes: request encoding (JSON escaping included) and response parsing (`choices[0].message.content` on success, `error.message` on failure; anything malformed is `null`, never a half-parsed conversation).
|
||||
- `@native-sdk/core/text` — the SDK's byte-splice text engine, compiled in for the composer's caret/selection/IME fidelity.
|
||||
|
||||
```sh
|
||||
NATIVE_SDK_CHAT_ENDPOINT="http://127.0.0.1:11434/v1/chat/completions" \
|
||||
NATIVE_SDK_CHAT_MODEL="<your model name>" \
|
||||
NATIVE_SDK_CHAT_API_KEY="local" \
|
||||
native dev # run the real app
|
||||
native dev --core --script dev-script.ndjson # the core-logic loop under node - no renderer, no network
|
||||
native check # subset-check the core's import graph + markup + app.zon
|
||||
```
|
||||
|
||||
The end-to-end proof battery lives in the SDK repo (`tests/ts-core/ai_chat_e2e_tests.zig`, run by `zig build test-ts-core-e2e`): it drives this example's real core and shipping markup headlessly through the teaching state (zero fetches without configuration), a scripted conversation with the request bytes pinned (`Authorization` header included), the in-flight guard, every failure shape, and record→replay with the launch variables unset and changed.
|
||||
|
||||
## Configuration: the env channel
|
||||
|
||||
The endpoint, model, and key arrive through the core's `envMsgs` channel — one journaled Msg per variable at install. The core never reads the environment (that would break determinism), **no endpoint is baked in, and no key exists anywhere in this tree**: until all three variables are present and non-empty, the app shows a setup panel naming exactly what is missing and issues zero requests.
|
||||
|
||||
- **`NATIVE_SDK_CHAT_ENDPOINT`** — the full chat-completions URL (for a local runtime, typically `http://127.0.0.1:<port>/v1/chat/completions`).
|
||||
- **`NATIVE_SDK_CHAT_MODEL`** — the model name the endpoint expects in the request body.
|
||||
- **`NATIVE_SDK_CHAT_API_KEY`** — the bearer token, sent as a standard `Authorization: Bearer <key>` header. Local OpenAI-compatible runtimes ignore auth; any placeholder satisfies the guard.
|
||||
|
||||
Record/replay journals these deliveries: a session recorded with the variables set replays byte-identically on a machine where they are unset or different — the recorded values feed from the journal, and replay never reads the environment.
|
||||
|
||||
## Where this example is honest about v1 boundaries
|
||||
|
||||
Every line below is a decided posture, listed on purpose:
|
||||
|
||||
- **The reply arrives whole, not streamed.** `Cmd.fetch` is buffered by design in v1 — one request, one `{ status, body }` result Msg. The UI shows an honest waiting state instead of a token stream. The effect engine underneath already frames streamed response bodies into line Msgs (the Zig effects channel's `.stream` fetch — exactly the shape SSE token streams arrive in); surfacing that in the TS Cmd vocabulary is the named roadmap item. Buffered is also what makes the replay trick trivial: one journaled result per request.
|
||||
- **A failed request keeps the conversation.** Every failure shape — a non-200 status (the endpoint's own `error.message` surfaces when the body carries one), a 200 whose body does not parse, a transport failure with its machine-readable reason — lands in one failed state with the history intact and a Retry that re-sends the same conversation.
|
||||
- **One request in flight, by construction.** `phase === "sending"` guards every send path in update (the Send button binds the same guard), and the `"chat"` effect key would reject a duplicate at the engine even if update misbehaved. A send blocked by the guard loses nothing — the draft survives.
|
||||
- **Long conversations eventually hit the request bound.** The engine's fetch body bound is 64 KiB; a conversation that outgrows it is rejected by the engine at runtime and lands in the failed state with a reason. Clear starts fresh. (History trimming/summarizing is app policy, deliberately not built in here.)
|
||||
- **The conversation is not persisted.** The Model is the session; `Cmd.writeFile` + a boot-time `Cmd.readFile` is the standard persistence pattern when an app wants history across launches.
|
||||
- **Desktop only.** TypeScript cores build desktop apps today.
|
||||
- **The encoder's helpers return byte arrays instead of appending to a shared buffer.** Local mutation ends at the first escape — an array passed to another function is no longer yours to mutate (the NS1051 "mutates after the array escaped" rule) — so `encodeChatRequest` assembles the request from values its helpers return, in one literal, rather than handing a parts buffer around between pushes.
|
||||
@@ -1,39 +0,0 @@
|
||||
# The chat client's core-logic loop, headless: replay with
|
||||
# native dev --core --script dev-script.ndjson
|
||||
# Msgs dispatch into update; the endpoint's answers are ordinary Msgs, so
|
||||
# the responses are fed back by hand exactly as the transcript's
|
||||
# `cmd fetch ...` lines invite — the same loop the native app runs, with
|
||||
# you standing in for the network.
|
||||
|
||||
# The launch configuration arrives through the env channel as ordinary
|
||||
# Msgs (under the real app the generated wiring dispatches these from the
|
||||
# environment at install). A local placeholder endpoint - nothing dials
|
||||
# out under the core host.
|
||||
{"kind":"endpoint_set","value":{"$bytes":"http://127.0.0.1:11434/v1/chat/completions"}}
|
||||
{"kind":"model_set","value":{"$bytes":"local-model"}}
|
||||
{"kind":"key_set","value":{"$bytes":"local"}}
|
||||
|
||||
# Type a message (the composer runs the SDK byte-splice text engine) and
|
||||
# send. The transcript shows the fetch command whole: POST, the endpoint,
|
||||
# the runtime-built "authorization: Bearer <key>" header, and the JSON
|
||||
# body - system prompt first, then the history.
|
||||
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"Say hi in two words"}}}
|
||||
{"kind":"send"}
|
||||
|
||||
# The endpoint's answer, fed back by hand: choices[0].message.content
|
||||
# parses into the assistant turn (escapes decode - note the \n).
|
||||
{"kind":"chat_response","status":200,"body":{"$bytes":"{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Hi\\nthere!\"}}]}"}}
|
||||
|
||||
# A second turn grows the history: watch the request body carry both
|
||||
# earlier turns before the new question.
|
||||
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"And a follow-up?"}}}
|
||||
{"kind":"send"}
|
||||
|
||||
# This time the endpoint fails with its own error body - the failed
|
||||
# state keeps the history and surfaces error.message as the reason.
|
||||
{"kind":"chat_response","status":500,"body":{"$bytes":"{\"error\":{\"message\":\"model overloaded\",\"type\":\"server_error\"}}"}}
|
||||
|
||||
# Retry re-sends the SAME conversation (no new turn); a success resolves
|
||||
# it into the fourth turn.
|
||||
{"kind":"retry"}
|
||||
{"kind":"chat_response","status":200,"body":{"$bytes":"{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Certainly.\"}}]}"}}
|
||||
@@ -1,120 +0,0 @@
|
||||
<!-- The chat client's whole view tier: one markup file over the TS core's
|
||||
model, bound by the names core.ts wrote (fields and exported helpers
|
||||
bind verbatim). The
|
||||
header carries the model badge and Clear, the conversation is a
|
||||
controlled scroll of role bubbles (user right/accent, assistant
|
||||
left/surface) with honest sending and failed rows, and the composer
|
||||
is a text-field on the core's byte-splice engine — Enter and the
|
||||
Send button dispatch the same `send` arm. Until the three launch
|
||||
variables arrive through the env channel, the teaching panel
|
||||
explains the setup and the app issues zero requests. -->
|
||||
<column background="background">
|
||||
<row height="52" padding="12" gap="10" cross="center" background="surface" label="Chat header">
|
||||
<text label="AI Chat"><span weight="bold" scale="1.1">AI Chat</span></text>
|
||||
<badge variant="secondary">{modelLabel}</badge>
|
||||
<spacer grow="1" />
|
||||
<if test="{sending}">
|
||||
<text size="sm" foreground="text_muted">waiting for the model…</text>
|
||||
</if>
|
||||
<button size="sm" variant="ghost" icon="trash" disabled="{clearDisabled}" on-press="clear" label="Clear conversation">Clear</button>
|
||||
</row>
|
||||
<separator />
|
||||
<if test="{unconfigured}">
|
||||
<!-- The teaching state: no endpoint is baked in and no request ever
|
||||
leaves an unconfigured app — the panel names exactly what is
|
||||
missing. -->
|
||||
<column grow="1" padding="24" main="center" cross="center" label="Setup">
|
||||
<panel padding="24" background="surface" radius="lg" width="520" label="Connect a model">
|
||||
<column gap="12">
|
||||
<text><span weight="bold">Connect a model</span></text>
|
||||
<text size="sm" foreground="text_muted">This app talks to an OpenAI-compatible chat-completions endpoint. Set all three variables and relaunch — the core reads them once, at install, through the env channel.</text>
|
||||
<row gap="8" cross="center">
|
||||
<text size="sm" grow="1">NATIVE_SDK_CHAT_ENDPOINT</text>
|
||||
<if test="{endpointMissing}">
|
||||
<text size="sm" foreground="destructive">missing</text>
|
||||
</if>
|
||||
<else>
|
||||
<text size="sm" foreground="success">set</text>
|
||||
</else>
|
||||
</row>
|
||||
<row gap="8" cross="center">
|
||||
<text size="sm" grow="1">NATIVE_SDK_CHAT_MODEL</text>
|
||||
<if test="{modelMissing}">
|
||||
<text size="sm" foreground="destructive">missing</text>
|
||||
</if>
|
||||
<else>
|
||||
<text size="sm" foreground="success">set</text>
|
||||
</else>
|
||||
</row>
|
||||
<row gap="8" cross="center">
|
||||
<text size="sm" grow="1">NATIVE_SDK_CHAT_API_KEY</text>
|
||||
<if test="{keyMissing}">
|
||||
<text size="sm" foreground="destructive">missing</text>
|
||||
</if>
|
||||
<else>
|
||||
<text size="sm" foreground="success">set</text>
|
||||
</else>
|
||||
</row>
|
||||
<text size="sm" foreground="text_muted">The README shows the full setup, including local OpenAI-compatible runtimes.</text>
|
||||
</column>
|
||||
</panel>
|
||||
</column>
|
||||
</if>
|
||||
<else>
|
||||
<scroll grow="1" label="Conversation" value="{chatScrollTop}" on-scroll="chat_scrolled">
|
||||
<column padding="24" gap="10">
|
||||
<if test="{emptyConversation}">
|
||||
<panel padding="24" background="surface" radius="lg" label="Empty conversation">
|
||||
<column gap="6">
|
||||
<text>Ask anything</text>
|
||||
<text size="sm" foreground="text_muted">The reply arrives whole — responses are buffered in v1, not streamed.</text>
|
||||
</column>
|
||||
</panel>
|
||||
</if>
|
||||
<for each="turnRows" as="t" key="id">
|
||||
<if test="{t.user}">
|
||||
<row key="{t.id}" gap="8" label="You said">
|
||||
<spacer grow="1" min-width="64" />
|
||||
<panel padding="12" background="accent" radius="lg">
|
||||
<text wrap="true" foreground="accent_text">{t.text}</text>
|
||||
</panel>
|
||||
</row>
|
||||
</if>
|
||||
<else>
|
||||
<row key="{t.id}" gap="8" label="The model said">
|
||||
<panel padding="12" background="surface" radius="lg">
|
||||
<text wrap="true">{t.text}</text>
|
||||
</panel>
|
||||
<spacer grow="1" min-width="64" />
|
||||
</row>
|
||||
</else>
|
||||
</for>
|
||||
<if test="{sending}">
|
||||
<row gap="8" label="Reply pending">
|
||||
<panel padding="12" background="surface" radius="lg">
|
||||
<text foreground="text_muted">…</text>
|
||||
</panel>
|
||||
<spacer grow="1" min-width="64" />
|
||||
</row>
|
||||
</if>
|
||||
<if test="{failed}">
|
||||
<panel padding="12" background="surface" radius="lg" label="Request failed">
|
||||
<row gap="10" cross="center">
|
||||
<icon name="alert" width="14" height="14" foreground="destructive" />
|
||||
<column gap="2" grow="1">
|
||||
<text size="sm" foreground="destructive">Request failed</text>
|
||||
<text size="sm" foreground="text_muted">{failReasonLabel}</text>
|
||||
</column>
|
||||
<button size="sm" variant="ghost" icon="refresh-cw" on-press="retry" label="Retry request">Retry</button>
|
||||
</row>
|
||||
</panel>
|
||||
</if>
|
||||
</column>
|
||||
</scroll>
|
||||
<separator />
|
||||
<row padding="12" gap="8" cross="center" background="surface" label="Composer">
|
||||
<text-field grow="1" text="{draftText}" placeholder="Message the model…" on-input="draft_edit" on-submit="send" label="Message" />
|
||||
<button variant="primary" icon="send" disabled="{sendDisabled}" on-press="send" label="Send message">Send</button>
|
||||
</row>
|
||||
</else>
|
||||
</column>
|
||||
@@ -1,409 +0,0 @@
|
||||
// ai-chat-ts core: a chat client for an OpenAI-compatible chat-completions
|
||||
// endpoint, authored entirely in the TypeScript app-core subset. Zero Zig
|
||||
// in this tree: the build transpiles this module and src/api.ts,
|
||||
// src/app.native is the whole view, app.zon the manifest.
|
||||
//
|
||||
// The core is two modules plus one SDK library, all under src/:
|
||||
//
|
||||
// core.ts (this file) Model, Msg, update, the wiring channels, and
|
||||
// every exported binding helper — the entry module is the
|
||||
// app's public face (markup and node both see its exports)
|
||||
// api.ts the chat-completions wire format in pure bytes: request
|
||||
// encoding, response parsing (choices[0].message.content and
|
||||
// error.message — exactly the fields the app reads)
|
||||
// @native-sdk/core/text the SDK's byte-splice text engine, transpiled
|
||||
// in for the composer's caret/selection/IME fidelity
|
||||
//
|
||||
// The whole network surface is ONE effect: `Cmd.fetch` on the "chat" key,
|
||||
// buffered (fetch streaming is consciously not in v1 — the reply arrives
|
||||
// whole; the README frames the roadmap). The in-flight discipline is
|
||||
// model-first: `phase === "sending"` blocks every re-send in update, so a
|
||||
// second request cannot exist while one is out — and the "chat" key backs
|
||||
// that up at the engine (a duplicate live key would be rejected, never
|
||||
// doubled).
|
||||
//
|
||||
// The endpoint, model name, and API key arrive through the `envMsgs`
|
||||
// channel as journaled Msgs at install — the core never reads the
|
||||
// environment (NS1005), which is exactly why a recorded conversation
|
||||
// replays byte-identically on a machine with none of the variables set.
|
||||
|
||||
import { Cmd, asciiBytes, type EnvMsg } from "@native-sdk/core";
|
||||
import {
|
||||
applyTextInputEvent,
|
||||
clampedInsertEvent,
|
||||
trimAsciiSpaces,
|
||||
type TextEditState,
|
||||
type TextInputEvent,
|
||||
} from "@native-sdk/core/text";
|
||||
// The SDK-provided scroll-state record (the shape markup's on-scroll
|
||||
// matches structurally - imported, so no in-file mirror can drift).
|
||||
import { type ScrollState } from "@native-sdk/core/events";
|
||||
import {
|
||||
bearerToken,
|
||||
encodeChatRequest,
|
||||
parseChatContent,
|
||||
parseErrorMessage,
|
||||
type Bytes,
|
||||
type Turn,
|
||||
} from "./api.ts";
|
||||
|
||||
/// The conversation's standing instruction, first in every request's
|
||||
/// message list. One constant, versioned with the app — not model state,
|
||||
/// so replay and the request pins never depend on it drifting.
|
||||
const SYSTEM_PROMPT = asciiBytes(
|
||||
"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.",
|
||||
);
|
||||
|
||||
/// The composer's byte capacity — comfortably under the engine's 64 KiB
|
||||
/// request-body bound with a long conversation around it.
|
||||
const MAX_DRAFT = 4096;
|
||||
|
||||
/// Assigning the scroll binding a value past the content clamps to the
|
||||
/// bottom — how a new message keeps the latest turn in view.
|
||||
const SCROLL_BOTTOM = 1000000;
|
||||
|
||||
// -------------------------------------------------------------- composer
|
||||
// The fixed-capacity editor state for the message field: the SDK text
|
||||
// engine does the byte splicing; this wrapper is the app's flat committed
|
||||
// shape for it (compStart -1 = no composition). Immutable: composerApply
|
||||
// returns a new value.
|
||||
|
||||
export interface ComposerDraft {
|
||||
readonly bytes: Bytes;
|
||||
readonly anchor: number;
|
||||
readonly focus: number;
|
||||
readonly compStart: number; // -1 when no composition
|
||||
readonly compEnd: number;
|
||||
}
|
||||
|
||||
function composerInit(): ComposerDraft {
|
||||
return { bytes: new Uint8Array(0), anchor: 0, focus: 0, compStart: -1, compEnd: -1 };
|
||||
}
|
||||
|
||||
function composerState(d: ComposerDraft): TextEditState {
|
||||
return {
|
||||
text: d.bytes,
|
||||
selection: { anchor: d.anchor, focus: d.focus },
|
||||
composition: d.compStart >= 0 ? { start: d.compStart, end: d.compEnd } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function composerApply(d: ComposerDraft, event: TextInputEvent): ComposerDraft {
|
||||
const state = composerState(d);
|
||||
const next = applyTextInputEvent(state, event, MAX_DRAFT);
|
||||
if (next === null) {
|
||||
// Over-capacity: clamp an insert to the bytes that fit (refuse-whole
|
||||
// for everything else) — the runtime TextBuffer's contract.
|
||||
const clamped = clampedInsertEvent(state, event, MAX_DRAFT);
|
||||
if (clamped === null) return d;
|
||||
const nextClamped = applyTextInputEvent(state, clamped, MAX_DRAFT);
|
||||
if (nextClamped === null) return d;
|
||||
// Composition bounds land in i64-classed slots: bind them, guard
|
||||
// the range (an ordered comparison excludes NaN), and state
|
||||
// wholeness with Math.trunc at the write; -1 stays the no-composition
|
||||
// sentinel.
|
||||
const clampedStart = nextClamped.composition !== null ? nextClamped.composition.start : -1;
|
||||
const clampedEnd = nextClamped.composition !== null ? nextClamped.composition.end : -1;
|
||||
return {
|
||||
bytes: nextClamped.text,
|
||||
anchor: nextClamped.selection.anchor,
|
||||
focus: nextClamped.selection.focus,
|
||||
compStart: clampedStart >= -1 && clampedStart <= 9007199254740991 ? Math.trunc(clampedStart) : -1,
|
||||
compEnd: clampedEnd >= -1 && clampedEnd <= 9007199254740991 ? Math.trunc(clampedEnd) : -1,
|
||||
};
|
||||
}
|
||||
const nextStart = next.composition !== null ? next.composition.start : -1;
|
||||
const nextEnd = next.composition !== null ? next.composition.end : -1;
|
||||
return {
|
||||
bytes: next.text,
|
||||
anchor: next.selection.anchor,
|
||||
focus: next.selection.focus,
|
||||
compStart: nextStart >= -1 && nextStart <= 9007199254740991 ? Math.trunc(nextStart) : -1,
|
||||
compEnd: nextEnd >= -1 && nextEnd <= 9007199254740991 ? Math.trunc(nextEnd) : -1,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ model
|
||||
|
||||
export type Phase = "idle" | "sending" | "failed";
|
||||
|
||||
export interface Model {
|
||||
/// The conversation, oldest first — user and assistant turns alike.
|
||||
/// Committed state, so record→replay carries the whole conversation.
|
||||
readonly turns: readonly Turn[];
|
||||
readonly nextId: number;
|
||||
/// The request lifecycle: `sending` is the in-flight guard (every
|
||||
/// re-send path checks it), `failed` keeps the history and shows the
|
||||
/// reason until the next send.
|
||||
readonly phase: Phase;
|
||||
/// Why the last request failed: the transport reason (`timed_out`,
|
||||
/// `connect_failed`, ...), the endpoint's own error.message, or the
|
||||
/// HTTP status line — never empty in the failed phase.
|
||||
readonly failReason: Bytes;
|
||||
readonly draft: ComposerDraft;
|
||||
/// The launch configuration (the envMsgs channel): the full
|
||||
/// chat-completions URL, the model name, and the API key. All three
|
||||
/// empty until their variables arrive; the app teaches setup until
|
||||
/// every one is non-empty.
|
||||
readonly endpoint: Bytes;
|
||||
readonly modelName: Bytes;
|
||||
readonly apiKey: Bytes;
|
||||
/// The conversation scroll offset, echoed from markup's `on-scroll`
|
||||
/// and pushed past the content on every new turn (the clamp lands it
|
||||
/// at the bottom) — the controlled-scroll shape.
|
||||
readonly chatScrollTop: number;
|
||||
}
|
||||
|
||||
export function initialModel(): Model {
|
||||
return {
|
||||
turns: [],
|
||||
nextId: 1,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
draft: composerInit(),
|
||||
endpoint: new Uint8Array(0),
|
||||
modelName: new Uint8Array(0),
|
||||
apiKey: new Uint8Array(0),
|
||||
chatScrollTop: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- msg
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "draft_edit"; readonly edit: TextInputEvent }
|
||||
/// The send gesture: the composer's Enter (markup `on-submit`) and the
|
||||
/// Send button dispatch the same arm.
|
||||
| { readonly kind: "send" }
|
||||
/// Re-issue the failed request over the history as it stands (the
|
||||
/// unanswered user turn is already the last entry).
|
||||
| { readonly kind: "retry" }
|
||||
| { readonly kind: "clear" }
|
||||
/// The delivered HTTP response, any status — the fetch ok arm.
|
||||
| { readonly kind: "chat_response"; readonly status: number; readonly body: Bytes }
|
||||
/// The transport failure — the fetch err arm's machine-readable reason.
|
||||
| { readonly kind: "chat_failed"; readonly reason: Bytes }
|
||||
| { readonly kind: "chat_scrolled"; readonly scroll: ScrollState }
|
||||
| { readonly kind: "endpoint_set"; readonly value: Bytes }
|
||||
| { readonly kind: "model_set"; readonly value: Bytes }
|
||||
| { readonly kind: "key_set"; readonly value: Bytes };
|
||||
|
||||
// --------------------------------------------------- host-event channels
|
||||
|
||||
/// The launch configuration channel: each variable present at launch
|
||||
/// dispatches one journaled Msg right after boot. NO default endpoint
|
||||
/// and NO baked key exist anywhere in this tree — an unconfigured app
|
||||
/// says so on screen instead of dialing a stranger.
|
||||
export const envMsgs: readonly EnvMsg<Msg>[] = [
|
||||
{ env: "NATIVE_SDK_CHAT_ENDPOINT", msg: "endpoint_set" },
|
||||
{ env: "NATIVE_SDK_CHAT_MODEL", msg: "model_set" },
|
||||
{ env: "NATIVE_SDK_CHAT_API_KEY", msg: "key_set" },
|
||||
];
|
||||
|
||||
/// Update-only state: host-fired Msg arms and the fields markup reads
|
||||
/// through the exported derived helpers instead of directly.
|
||||
export const viewUnbound = [
|
||||
"chat_response",
|
||||
"chat_failed",
|
||||
"endpoint_set",
|
||||
"model_set",
|
||||
"key_set",
|
||||
"turns",
|
||||
"nextId",
|
||||
"phase",
|
||||
"failReason",
|
||||
"draft",
|
||||
"endpoint",
|
||||
"modelName",
|
||||
"apiKey",
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------- derived
|
||||
|
||||
function isConfigured(model: Model): boolean {
|
||||
return model.endpoint.length > 0 && model.modelName.length > 0 && model.apiKey.length > 0;
|
||||
}
|
||||
|
||||
/// The teaching state: some launch variable is missing, so the app can
|
||||
/// only explain how to connect a model — and issues zero requests.
|
||||
export function unconfigured(model: Model): boolean {
|
||||
return !isConfigured(model);
|
||||
}
|
||||
|
||||
export function endpointMissing(model: Model): boolean {
|
||||
return model.endpoint.length === 0;
|
||||
}
|
||||
|
||||
export function modelMissing(model: Model): boolean {
|
||||
return model.modelName.length === 0;
|
||||
}
|
||||
|
||||
export function keyMissing(model: Model): boolean {
|
||||
return model.apiKey.length === 0;
|
||||
}
|
||||
|
||||
export function sending(model: Model): boolean {
|
||||
return model.phase === "sending";
|
||||
}
|
||||
|
||||
export function failed(model: Model): boolean {
|
||||
return model.phase === "failed";
|
||||
}
|
||||
|
||||
export function failReasonLabel(model: Model): Bytes {
|
||||
return model.failReason;
|
||||
}
|
||||
|
||||
export function draftText(model: Model): Bytes {
|
||||
return model.draft.bytes;
|
||||
}
|
||||
|
||||
export function emptyConversation(model: Model): boolean {
|
||||
return model.turns.length === 0;
|
||||
}
|
||||
|
||||
/// The header's model badge: the configured name, or the gap it teaches.
|
||||
export function modelLabel(model: Model): Bytes {
|
||||
return model.modelName.length > 0 ? model.modelName : asciiBytes("no model configured");
|
||||
}
|
||||
|
||||
export function sendDisabled(model: Model): boolean {
|
||||
return model.phase === "sending" || !isConfigured(model);
|
||||
}
|
||||
|
||||
export function clearDisabled(model: Model): boolean {
|
||||
return model.phase === "sending" || model.turns.length === 0;
|
||||
}
|
||||
|
||||
/// One conversation row for markup's `for each`: the role flag picks the
|
||||
/// bubble side and colors.
|
||||
export interface TurnRow {
|
||||
readonly id: number;
|
||||
readonly user: boolean;
|
||||
readonly text: Bytes;
|
||||
}
|
||||
|
||||
export function turnRows(model: Model): readonly TurnRow[] {
|
||||
return model.turns.map((t) => ({ id: t.id, user: t.role === "user", text: t.text }));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- update
|
||||
|
||||
export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "draft_edit":
|
||||
return [{ ...model, draft: composerApply(model.draft, msg.edit) }, Cmd.none];
|
||||
case "send": {
|
||||
// The in-flight guard: one request at a time, by model state — a
|
||||
// second send while one is out is a no-op, so the "chat" key can
|
||||
// never collide at the engine.
|
||||
if (!isConfigured(model) || model.phase === "sending") return [model, Cmd.none];
|
||||
const text = trimAsciiSpaces(model.draft.bytes);
|
||||
if (text.length === 0) return [model, Cmd.none];
|
||||
const turns: readonly Turn[] = [...model.turns, { id: model.nextId, role: "user", text: text }];
|
||||
return [
|
||||
{
|
||||
...model,
|
||||
turns: turns,
|
||||
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
|
||||
phase: "sending",
|
||||
failReason: new Uint8Array(0),
|
||||
draft: composerInit(),
|
||||
chatScrollTop: SCROLL_BOTTOM,
|
||||
},
|
||||
Cmd.fetch(
|
||||
{
|
||||
url: model.endpoint,
|
||||
method: "POST",
|
||||
// The bearer token is a RUNTIME header value (built from the
|
||||
// launch-supplied key); header names stay compile-time.
|
||||
headers: { authorization: bearerToken(model.apiKey), "content-type": "application/json" },
|
||||
body: encodeChatRequest(model.modelName, SYSTEM_PROMPT, turns),
|
||||
timeoutMs: 120000,
|
||||
},
|
||||
{ key: "chat", ok: "chat_response", err: "chat_failed" },
|
||||
),
|
||||
];
|
||||
}
|
||||
case "retry": {
|
||||
// Re-send the conversation as it stands: only from the failed
|
||||
// state, and only when the last turn is the unanswered user turn.
|
||||
if (model.phase !== "failed" || !isConfigured(model)) return [model, Cmd.none];
|
||||
if (model.turns.length === 0) return [model, Cmd.none];
|
||||
if (model.turns[model.turns.length - 1].role !== "user") return [model, Cmd.none];
|
||||
return [
|
||||
{ ...model, phase: "sending", failReason: new Uint8Array(0) },
|
||||
Cmd.fetch(
|
||||
{
|
||||
url: model.endpoint,
|
||||
method: "POST",
|
||||
headers: { authorization: bearerToken(model.apiKey), "content-type": "application/json" },
|
||||
body: encodeChatRequest(model.modelName, SYSTEM_PROMPT, model.turns),
|
||||
timeoutMs: 120000,
|
||||
},
|
||||
{ key: "chat", ok: "chat_response", err: "chat_failed" },
|
||||
),
|
||||
];
|
||||
}
|
||||
case "clear": {
|
||||
if (model.phase === "sending" || model.turns.length === 0) return [model, Cmd.none];
|
||||
return [{
|
||||
...model,
|
||||
turns: [],
|
||||
nextId: 1,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
chatScrollTop: 0,
|
||||
}, Cmd.none];
|
||||
}
|
||||
case "chat_response": {
|
||||
// The "chat" key carries exactly one live request and the sending
|
||||
// guard blocks re-sends, so a response outside the sending phase
|
||||
// can only be stale — drop it rather than corrupt the history.
|
||||
if (model.phase !== "sending") return [model, Cmd.none];
|
||||
if (msg.status === 200) {
|
||||
const content = parseChatContent(msg.body);
|
||||
if (content === null) {
|
||||
// A 200 whose body is not a chat completion is a failed
|
||||
// request, never a half-parsed conversation.
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: asciiBytes("the response did not parse as a chat completion"),
|
||||
}, Cmd.none];
|
||||
}
|
||||
return [{
|
||||
...model,
|
||||
turns: [...model.turns, { id: model.nextId, role: "assistant", text: content }],
|
||||
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
|
||||
phase: "idle",
|
||||
chatScrollTop: SCROLL_BOTTOM,
|
||||
}, Cmd.none];
|
||||
}
|
||||
// Any other status is a delivered response whose meaning is "the
|
||||
// endpoint said no": surface its own error.message when the body
|
||||
// carries one, the bare status line when it does not.
|
||||
const message = parseErrorMessage(msg.body);
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: message ?? asciiBytes(`the endpoint answered HTTP ${msg.status}`),
|
||||
}, Cmd.none];
|
||||
}
|
||||
case "chat_failed":
|
||||
// The transport reason is machine-readable (`timed_out`,
|
||||
// `connect_failed`, `truncated`, ...) — shown as-is, never silence.
|
||||
return [{ ...model, phase: "failed", failReason: msg.reason }, Cmd.none];
|
||||
case "chat_scrolled":
|
||||
// The controlled-scroll echo: the applied offset lands in the
|
||||
// model, so the next rebuild's `value` binding never fights the
|
||||
// runtime.
|
||||
return [{ ...model, chatScrollTop: msg.scroll.offsetY }, Cmd.none];
|
||||
case "endpoint_set":
|
||||
return [{ ...model, endpoint: msg.value }, Cmd.none];
|
||||
case "model_set":
|
||||
return [{ ...model, modelName: msg.value }, Cmd.none];
|
||||
case "key_set":
|
||||
return [{ ...model, apiKey: msg.value }, Cmd.none];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Native SDK Chatbot example
|
||||
|
||||
A streaming chat client for [Vercel AI Gateway](https://vercel.com/docs/ai-gateway), authored entirely in **TypeScript + Native markup**. Zero Zig: the logic tier is the app-core subset under `src/`, compiled to native code at build time; `src/app.native` is the whole view tier and `app.zon` the manifest. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
|
||||
|
||||
This is the reference answer to "can a TypeScript core stream an AI API?": the network surface is one line-routed `Cmd.fetch` to `https://ai-gateway.vercel.sh/v1/chat/completions`, with `stream: true`, `Accept: text/event-stream`, and a real `Authorization: Bearer <key>` header built at runtime. Each SSE `data:` line is an ordinary Msg; `choices[0].delta.content` is appended to committed Model state and repaints the assistant response immediately. The JSON/SSE wire format is pure byte math in the subset, and because the whole exchange is effect data, a recorded conversation **replays byte-identically with zero network and zero env reads** — including every partial reply.
|
||||
|
||||
The core is two modules plus two SDK libraries:
|
||||
|
||||
- `src/core.ts` — the entry module: Model (completed history, the in-progress assistant reply, the composer, the request phase, and launch configuration), Msg, update, the env channel, and every exported binding helper.
|
||||
- `src/api.ts` — the Gateway chat-completions wire format over bytes: request encoding (JSON escaping included) and SSE parsing (`choices[0].delta.content`, `[DONE]`, and `error.message`).
|
||||
- `@native-sdk/core/text` — the SDK's byte-splice text engine, compiled in for the composer's caret/selection/IME fidelity.
|
||||
- `@native-sdk/core/events` — canonical scroll and window-chrome event shapes; the latter drives the text-free, draggable hidden titlebar and keeps its New chat button clear of the traffic lights.
|
||||
|
||||
```sh
|
||||
AI_GATEWAY_API_KEY="<your Vercel AI Gateway key>" \
|
||||
native dev # run the real app
|
||||
native dev --core --script dev-script.ndjson # the core-logic loop under node - no renderer, no network
|
||||
native check # subset-check the core's import graph + markup + app.zon
|
||||
```
|
||||
|
||||
The example defaults to `openai/gpt-5.6-luna`. The model selector inside the prompt group offers GPT-5.6 Luna, GPT-5.6 Terra, and GPT-5.6 Sol, backed by `openai/gpt-5.6-luna`, `openai/gpt-5.6-terra`, and `openai/gpt-5.6-sol`; the next request uses the selected model. To start with another Gateway model, add `NATIVE_SDK_CHAT_MODEL="<creator/model>"` to the `native dev` command.
|
||||
|
||||
The end-to-end proof battery lives in the SDK repo (`tests/ts-core/ai_chat_e2e_tests.zig`, run by `zig build test-ts-core-e2e`): it drives this example's real core and shipping markup headlessly through the teaching state (zero fetches without configuration), a scripted conversation with the Gateway request bytes pinned, partial text asserted before the terminal, the in-flight guard, every failure shape, and record→replay with the launch variables unset and changed.
|
||||
|
||||
## Configuration: the env channel
|
||||
|
||||
The API key and optional model override arrive through the core's `envMsgs` channel — one journaled Msg per variable present at install. The core never reads the environment (that would break determinism). The Vercel AI Gateway Chat Completions endpoint and the `openai/gpt-5.6-luna` default are intentionally fixed and reviewable in `src/core.ts`; **no key exists anywhere in this tree**. Until the key is present and non-empty, the app shows a setup panel and issues zero requests.
|
||||
|
||||
- **`NATIVE_SDK_CHAT_MODEL`** *(optional)* — overrides the `openai/gpt-5.6-luna` default with another Gateway `creator/model` id from the [model catalog](https://vercel.com/ai-gateway/models). An empty value leaves the default in place.
|
||||
- **`AI_GATEWAY_API_KEY`** — a Vercel AI Gateway API key, sent as the standard `Authorization: Bearer <key>` header.
|
||||
|
||||
Record/replay journals these deliveries: a session recorded with the variables set replays byte-identically on a machine where they are unset or different — the recorded values feed from the journal, and replay never reads the environment.
|
||||
|
||||
## Where this example is honest about v1 boundaries
|
||||
|
||||
Every line below is a decided posture, listed on purpose:
|
||||
|
||||
- **Replies really stream.** `Cmd.fetch` routes every complete SSE line through `chat_line`; the core decodes `choices[0].delta.content`, appends it to `pendingReply`, and the markup displays that field while the request is live. `[DONE]` plus a 2xx terminal commits the completed assistant turn.
|
||||
- **A failed request keeps the conversation.** Every failure shape — a non-2xx status (the Gateway's own `error.message` surfaces when a response line carries one), a 2xx stream missing `[DONE]`, a textless completion, or a transport failure — lands in one failed state. Partial assistant text is discarded; the unanswered user turn stays, and Retry re-sends the same history.
|
||||
- **One request in flight, by construction.** `phase === "sending"` guards every send path in update, and the `"chat"` effect key would reject a duplicate at the engine even if update misbehaved. While a reply streams, the Send action becomes Stop: it cancels the keyed request immediately and keeps any text already received as the assistant's stopped response. A send blocked by the guard loses nothing — the draft survives.
|
||||
- **Long conversations retain full visible history and prune provider context.** Before each send, the encoder measures the exact JSON-escaped size and sends the newest whole, user-led suffix that fits the engine's 64 KiB fetch-body bound. Older turns remain in the UI and the session Model; they simply stop riding the API request. If the fixed request fields and newest prompt alone cannot fit, the app enters its failed state locally instead of issuing a rejected fetch.
|
||||
- **One streamed reply is capped at 256 KiB.** Crossing the cap cancels the live request, reports the failure, and keeps the unanswered user turn. Individual protocol lines use a 64 KiB bound.
|
||||
- **The conversation is not persisted.** The Model is the session; `Cmd.writeFile` + a boot-time `Cmd.readFile` is the standard persistence pattern when an app wants history across launches.
|
||||
- **Desktop only.** TypeScript cores build desktop apps today.
|
||||
- **The encoder's helpers return byte arrays instead of appending to a shared buffer.** Local mutation ends at the first escape — an array passed to another function is no longer yours to mutate (the NS1051 "mutates after the array escaped" rule) — so `encodeChatRequest` assembles the request from values its helpers return, in one literal, rather than handing a parts buffer around between pushes. The bounded wrapper measures those values first and never assembles an oversized complete body.
|
||||
@@ -1,12 +1,12 @@
|
||||
.{
|
||||
.id = "dev.native_sdk.ai_chat_ts",
|
||||
.name = "ai-chat-ts",
|
||||
.display_name = "AI Chat TS",
|
||||
.description = "A chat client for an OpenAI-compatible endpoint, authored entirely in TypeScript + Native markup.",
|
||||
.id = "dev.native_sdk.chatbot",
|
||||
.name = "chatbot",
|
||||
.display_name = "Chatbot",
|
||||
.description = "A streaming Vercel AI Gateway chat client, authored entirely in TypeScript + Native markup.",
|
||||
.version = "0.1.0",
|
||||
.platforms = .{"macos"},
|
||||
// The network permission covers the one effect the app performs:
|
||||
// the buffered `Cmd.fetch` exchange with the configured
|
||||
// the streaming `Cmd.fetch` exchange with Vercel AI Gateway's fixed
|
||||
// chat-completions endpoint. Nothing else leaves the process.
|
||||
.permissions = .{ "view", "network" },
|
||||
.capabilities = .{ "native_views", "gpu_surfaces" },
|
||||
@@ -16,13 +16,14 @@
|
||||
.windows = .{
|
||||
.{
|
||||
.label = "main",
|
||||
.title = "AI Chat TS",
|
||||
.title = "Chatbot",
|
||||
.width = 760,
|
||||
.height = 640,
|
||||
.min_width = 560,
|
||||
.min_height = 420,
|
||||
.restore_state = false,
|
||||
.restore_policy = "center_on_primary",
|
||||
.titlebar = "hidden_inset_tall",
|
||||
.views = .{
|
||||
.{ .label = "chat-canvas", .kind = "gpu_surface", .fill = true, .role = "Chat canvas", .accessibility_label = "AI chat conversation", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
|
||||
},
|
||||
@@ -0,0 +1,43 @@
|
||||
# The chat client's core-logic loop, headless: replay with
|
||||
# native dev --core --script dev-script.ndjson
|
||||
# Msgs dispatch into update; each Gateway SSE line is an ordinary Msg, so
|
||||
# deltas and terminals are fed back by hand exactly as the transcript's
|
||||
# `cmd fetch ...` lines invite — the same loop the native app runs, with
|
||||
# you standing in for the network.
|
||||
|
||||
# The API key arrives through the env channel as an ordinary Msg (under
|
||||
# the real app the generated wiring dispatches it from the environment
|
||||
# at install). The endpoint and openai/gpt-5.6-luna default are fixed in
|
||||
# core.ts; nothing dials out under the core host.
|
||||
{"kind":"key_set","value":{"$bytes":"example-gateway-key"}}
|
||||
|
||||
# Type a message (the composer runs the SDK byte-splice text engine) and
|
||||
# send. The transcript shows the streaming fetch command whole: POST, the
|
||||
# Gateway endpoint, SSE accept and runtime-built authorization headers,
|
||||
# and the JSON body — system prompt first, then history and stream:true.
|
||||
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"Say hi in two words"}}}
|
||||
{"kind":"send"}
|
||||
|
||||
# Two Gateway deltas grow the pending assistant response immediately. The
|
||||
# explicit [DONE] marker and HTTP terminal then commit the whole turn.
|
||||
{"kind":"chat_line","line":{"$bytes":"data: {\"choices\":[{\"delta\":{\"content\":\"Hi\\n\"}}]}"}}
|
||||
{"kind":"chat_line","line":{"$bytes":"data: {\"choices\":[{\"delta\":{\"content\":\"there!\"}}]}"}}
|
||||
{"kind":"chat_line","line":{"$bytes":"data: [DONE]"}}
|
||||
{"kind":"chat_done","status":200}
|
||||
|
||||
# A second turn grows the history: watch the request body carry both
|
||||
# earlier turns before the new question.
|
||||
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"And a follow-up?"}}}
|
||||
{"kind":"send"}
|
||||
|
||||
# This time the Gateway fails with its own JSON error line — the failed
|
||||
# state keeps the history and surfaces error.message as the reason.
|
||||
{"kind":"chat_line","line":{"$bytes":"{\"error\":{\"message\":\"model overloaded\",\"type\":\"server_error\"}}"}}
|
||||
{"kind":"chat_done","status":500}
|
||||
|
||||
# Retry re-sends the SAME conversation (no new turn); a success resolves
|
||||
# it into the fourth turn.
|
||||
{"kind":"retry"}
|
||||
{"kind":"chat_line","line":{"$bytes":"data: {\"choices\":[{\"delta\":{\"content\":\"Certainly.\"}}]}"}}
|
||||
{"kind":"chat_line","line":{"$bytes":"data: [DONE]"}}
|
||||
{"kind":"chat_done","status":200}
|
||||
@@ -1,12 +1,10 @@
|
||||
// ai-chat-ts api module: the OpenAI-compatible chat-completions wire
|
||||
// format in pure subset TypeScript over bytes — request encoding on the
|
||||
// way out, response parsing on the way back. No JSON runtime exists in a
|
||||
// core (the binary carries no JS engine), and none is needed: the request
|
||||
// is a byte concatenation with one escape routine, and the response walk
|
||||
// reads exactly the two fields the app uses (`choices[0].message.content`
|
||||
// on success, `error.message` on failure) and refuses everything
|
||||
// malformed with `null` — a body that does not parse is a failed request,
|
||||
// never a half-parsed conversation.
|
||||
// Chatbot API module: the Vercel AI Gateway's OpenAI-compatible
|
||||
// chat-completions wire format in pure subset TypeScript over bytes —
|
||||
// request encoding on the way out, SSE parsing on the way back. No JSON
|
||||
// runtime exists in a core (the binary carries no JS engine), and none is
|
||||
// needed: the request is byte concatenation with one escape routine, and
|
||||
// each response-line walk reads exactly the fields the app uses
|
||||
// (`choices[0].delta.content` or `error.message`).
|
||||
//
|
||||
// Everything here is deterministic byte math, which is what makes the
|
||||
// announcement trick work: the exact request bytes are pinned in the e2e
|
||||
@@ -27,6 +25,15 @@ export interface Turn {
|
||||
readonly text: Bytes;
|
||||
}
|
||||
|
||||
/// The only four meanings the chat UI needs from one response line.
|
||||
/// Role-only and finish-reason chunks are valid but carry no visible
|
||||
/// text, so they are deliberately `ignore` rather than parse failures.
|
||||
export type ChatStreamEvent =
|
||||
| { readonly kind: "ignore" }
|
||||
| { readonly kind: "delta"; readonly text: Bytes }
|
||||
| { readonly kind: "done" }
|
||||
| { readonly kind: "error"; readonly message: Bytes };
|
||||
|
||||
/// How deep a response's nesting may go before the scanner refuses it —
|
||||
/// a bound on recursion, not on honest responses (a chat-completions
|
||||
/// body nests four levels).
|
||||
@@ -34,9 +41,17 @@ const MAX_JSON_DEPTH = 64;
|
||||
|
||||
// ------------------------------------------------------------- request
|
||||
|
||||
const REQUEST_MODEL_OPEN = asciiBytes('{"model":');
|
||||
const REQUEST_MESSAGES_OPEN = asciiBytes(',"messages":[{"role":"system","content":');
|
||||
const MESSAGE_CLOSE = asciiBytes("}");
|
||||
const USER_TURN_OPEN = asciiBytes(',{"role":"user","content":');
|
||||
const ASSISTANT_TURN_OPEN = asciiBytes(',{"role":"assistant","content":');
|
||||
const REQUEST_CLOSE = asciiBytes('],"stream":true}');
|
||||
|
||||
/// The chat-completions request body:
|
||||
/// `{"model":…,"messages":[{"role":"system","content":…},…]}` with the
|
||||
/// system prompt first and every conversation turn after it, in order.
|
||||
/// `{"model":…,"messages":[{"role":"system","content":…},…],"stream":true}`
|
||||
/// with the system prompt first and every conversation turn after it, in
|
||||
/// order. `stream: true` selects the Gateway's SSE response.
|
||||
/// The caller supplies the model name from the launch environment; the
|
||||
/// turns are the Model's history including the just-appended user turn.
|
||||
/// Helpers RETURN their bytes and this one builder assembles them in a
|
||||
@@ -45,25 +60,68 @@ const MAX_JSON_DEPTH = 64;
|
||||
/// each turn arrives pre-concatenated from `encodeTurn` instead.
|
||||
export function encodeChatRequest(modelName: Bytes, systemPrompt: Bytes, turns: readonly Turn[]): Bytes {
|
||||
return concatAll([
|
||||
asciiBytes('{"model":'),
|
||||
REQUEST_MODEL_OPEN,
|
||||
jsonString(modelName),
|
||||
asciiBytes(',"messages":[{"role":"system","content":'),
|
||||
REQUEST_MESSAGES_OPEN,
|
||||
jsonString(systemPrompt),
|
||||
asciiBytes("}"),
|
||||
MESSAGE_CLOSE,
|
||||
...turns.map((turn) => encodeTurn(turn)),
|
||||
asciiBytes("]}"),
|
||||
REQUEST_CLOSE,
|
||||
]);
|
||||
}
|
||||
|
||||
/// Encode the newest contiguous, user-led suffix that fits `maxBytes`.
|
||||
/// The Model keeps every turn; only the provider context is pruned. The
|
||||
/// exact JSON-escaped size is measured first, so this never constructs a
|
||||
/// complete oversized request just to discover that the engine rejects it.
|
||||
/// An empty result means even the fixed envelope and newest user turn do
|
||||
/// not fit, which lets the caller fail locally instead of issuing a fetch.
|
||||
export function encodeChatRequestWithinLimit(
|
||||
modelName: Bytes,
|
||||
systemPrompt: Bytes,
|
||||
turns: readonly Turn[],
|
||||
maxBytes: number,
|
||||
): Bytes {
|
||||
let total =
|
||||
REQUEST_MODEL_OPEN.length +
|
||||
jsonStringLength(modelName) +
|
||||
REQUEST_MESSAGES_OPEN.length +
|
||||
jsonStringLength(systemPrompt) +
|
||||
MESSAGE_CLOSE.length +
|
||||
REQUEST_CLOSE.length;
|
||||
if (total > maxBytes) return new Uint8Array(0);
|
||||
|
||||
let start = turns.length;
|
||||
while (start > 0) {
|
||||
const candidate = start - 1;
|
||||
const turnLength = encodedTurnLength(turns[candidate]);
|
||||
if (total + turnLength > maxBytes) break;
|
||||
total += turnLength;
|
||||
start = candidate;
|
||||
}
|
||||
|
||||
// A request with history must include its newest user turn. If that
|
||||
// turn did not fit, there is no useful smaller suffix to send.
|
||||
if (turns.length > 0 && start === turns.length) return new Uint8Array(0);
|
||||
|
||||
// Truncation can land just before an assistant response. Drop that
|
||||
// orphaned response so the retained context always begins with a user.
|
||||
while (start < turns.length && turns[start].role !== "user") start += 1;
|
||||
if (turns.length > 0 && start === turns.length) return new Uint8Array(0);
|
||||
return encodeChatRequest(modelName, systemPrompt, turns.slice(start));
|
||||
}
|
||||
|
||||
/// One conversation turn as its complete message-object bytes:
|
||||
/// `,{"role":…,"content":…}` — comma included, since every turn follows
|
||||
/// the system message.
|
||||
function encodeTurn(turn: Turn): Bytes {
|
||||
const open =
|
||||
turn.role === "user"
|
||||
? asciiBytes(',{"role":"user","content":')
|
||||
: asciiBytes(',{"role":"assistant","content":');
|
||||
return concatAll([open, jsonString(turn.text), asciiBytes("}")]);
|
||||
const open = turn.role === "user" ? USER_TURN_OPEN : ASSISTANT_TURN_OPEN;
|
||||
return concatAll([open, jsonString(turn.text), MESSAGE_CLOSE]);
|
||||
}
|
||||
|
||||
function encodedTurnLength(turn: Turn): number {
|
||||
const openLength = turn.role === "user" ? USER_TURN_OPEN.length : ASSISTANT_TURN_OPEN.length;
|
||||
return openLength + jsonStringLength(turn.text) + MESSAGE_CLOSE.length;
|
||||
}
|
||||
|
||||
/// A JSON string literal (quotes included) from UTF-8 text bytes. Two
|
||||
@@ -72,17 +130,7 @@ function encodeTurn(turn: Turn): Bytes {
|
||||
/// helper would have escaped and become immutable), so every escape is
|
||||
/// written inline. Non-ASCII UTF-8 bytes pass through raw (valid JSON).
|
||||
export function jsonString(text: Bytes): Bytes {
|
||||
let len = 2;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const b = text[i];
|
||||
if (b === 0x22 || b === 0x5c || b === 0x08 || b === 0x09 || b === 0x0a || b === 0x0c || b === 0x0d) {
|
||||
len += 2;
|
||||
} else if (b < 0x20) {
|
||||
len += 6; // \u00XX
|
||||
} else {
|
||||
len += 1;
|
||||
}
|
||||
}
|
||||
const len = jsonStringLength(text);
|
||||
const out = new Uint8Array(len);
|
||||
out[0] = 0x22;
|
||||
let at = 1;
|
||||
@@ -113,6 +161,21 @@ export function jsonString(text: Bytes): Bytes {
|
||||
return out;
|
||||
}
|
||||
|
||||
function jsonStringLength(text: Bytes): number {
|
||||
let len = 2;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const b = text[i];
|
||||
if (b === 0x22 || b === 0x5c || b === 0x08 || b === 0x09 || b === 0x0a || b === 0x0c || b === 0x0d) {
|
||||
len += 2;
|
||||
} else if (b < 0x20) {
|
||||
len += 6; // \u00XX
|
||||
} else {
|
||||
len += 1;
|
||||
}
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
/// The letter of a two-byte JSON escape: \b \t \n \f \r.
|
||||
function escapeLetter(b: number): number {
|
||||
if (b === 0x08) return 0x62;
|
||||
@@ -149,11 +212,42 @@ export function bearerToken(apiKey: Bytes): Bytes {
|
||||
|
||||
// ------------------------------------------------------------ response
|
||||
|
||||
/// `choices[0].message.content` from a chat-completions success body, or
|
||||
/// null when the body is not that shape (malformed JSON, empty choices,
|
||||
/// a non-string content) — the caller turns null into the failed state.
|
||||
export function parseChatContent(body: Bytes): Bytes | null {
|
||||
let at = skipWs(body, 0);
|
||||
/// Parse one complete response line from the Gateway. Successful
|
||||
/// chat-completions streams are SSE (`data: <json>` and `data: [DONE]`).
|
||||
/// A non-2xx response can instead arrive as a plain one-line JSON error,
|
||||
/// so the error parser also checks the raw line.
|
||||
export function parseChatStreamLine(line: Bytes): ChatStreamEvent {
|
||||
const payload = sseData(line);
|
||||
if (payload === null) {
|
||||
const rawError = parseErrorMessage(line);
|
||||
return rawError === null ? { kind: "ignore" } : { kind: "error", message: rawError };
|
||||
}
|
||||
if (bytesEqual(payload, asciiBytes("[DONE]"))) return { kind: "done" };
|
||||
const error = parseErrorMessage(payload);
|
||||
if (error !== null) return { kind: "error", message: error };
|
||||
const delta = parseChatDelta(payload);
|
||||
return delta === null ? { kind: "ignore" } : { kind: "delta", text: delta };
|
||||
}
|
||||
|
||||
/// The bytes after an SSE `data:` field, with the optional one space and
|
||||
/// a trailing CR removed. Other SSE fields and blank separator lines are
|
||||
/// not chat payloads.
|
||||
function sseData(line: Bytes): Bytes | null {
|
||||
if (line.length < 5) return null;
|
||||
if (line[0] !== 0x64 || line[1] !== 0x61 || line[2] !== 0x74 || line[3] !== 0x61 || line[4] !== 0x3a) {
|
||||
return null;
|
||||
}
|
||||
let start = 5;
|
||||
if (start < line.length && line[start] === 0x20) start += 1;
|
||||
let end = line.length;
|
||||
if (end > start && line[end - 1] === 0x0d) end -= 1;
|
||||
return line.slice(start, end);
|
||||
}
|
||||
|
||||
/// `choices[0].delta.content` from one OpenAI-compatible stream event,
|
||||
/// or null when this chunk carries no visible content.
|
||||
function parseChatDelta(body: Bytes): Bytes | null {
|
||||
const at = skipWs(body, 0);
|
||||
if (at >= body.length || body[at] !== 0x7b) return null; // {
|
||||
const choicesAt = memberValue(body, at, asciiBytes("choices"));
|
||||
if (choicesAt === -1) return null;
|
||||
@@ -162,9 +256,9 @@ export function parseChatContent(body: Bytes): Bytes | null {
|
||||
cursor = skipWs(body, cursor + 1);
|
||||
if (cursor >= body.length || body[cursor] === 0x5d) return null; // empty choices
|
||||
if (body[cursor] !== 0x7b) return null;
|
||||
const messageAt = memberValue(body, cursor, asciiBytes("message"));
|
||||
if (messageAt === -1) return null;
|
||||
cursor = skipWs(body, messageAt);
|
||||
const deltaAt = memberValue(body, cursor, asciiBytes("delta"));
|
||||
if (deltaAt === -1) return null;
|
||||
cursor = skipWs(body, deltaAt);
|
||||
if (cursor >= body.length || body[cursor] !== 0x7b) return null;
|
||||
const contentAt = memberValue(body, cursor, asciiBytes("content"));
|
||||
if (contentAt === -1) return null;
|
||||
@@ -230,6 +324,14 @@ function bytesEqualRange(b: Bytes, start: number, end: number, key: Bytes): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
function bytesEqual(left: Bytes, right: Bytes): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
if (left[i] !== right[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The index of a string's closing quote (escape-aware, undecoded), with
|
||||
/// `at` on the opening quote — or -1 when the string never closes.
|
||||
function rawStringEnd(b: Bytes, at: number): number {
|
||||
@@ -0,0 +1,142 @@
|
||||
<!-- The chat client's whole view tier: one markup file over the TS core's
|
||||
model, bound by the names core.ts wrote (fields and exported helpers
|
||||
bind verbatim). The custom hidden titlebar carries only a New chat
|
||||
icon button, and its drag region stays clear of the traffic lights
|
||||
through core.ts's chromeMsg channel. The conversation is a
|
||||
controlled scroll of right-aligned user bubbles and full-width
|
||||
assistant text that grows as SSE deltas arrive, with honest sending
|
||||
and failed rows, and the composer
|
||||
is an input group over the core's byte-splice engine — Enter
|
||||
and the arrow button dispatch the same `send` arm, while that action
|
||||
becomes an immediate stream-cancelling Stop button during a reply. Until the Gateway API key
|
||||
arrives through the env channel, the teaching panel
|
||||
explains the setup and the app issues zero requests. -->
|
||||
<column background="background">
|
||||
<!-- The header IS the titlebar (tall hidden-inset chrome), matching the
|
||||
Kanban example. There is deliberately no visible title text. -->
|
||||
<row height="{headerHeight}" padding="12" cross="center" window-drag="true" label="Chat header">
|
||||
<spacer width="{chromeLeading}" />
|
||||
<spacer grow="1" />
|
||||
<button size="icon" variant="ghost" icon="plus" on-press="clear" label="New chat"></button>
|
||||
</row>
|
||||
<separator />
|
||||
<!-- The titlebar stays edge-to-edge. Everything below it fills narrow
|
||||
windows and centers at a readable 960pt ceiling in wide ones. -->
|
||||
<row grow="1" main="center" label="Chat body">
|
||||
<column grow="1" max-width="960" label="Chat content">
|
||||
<if test="{unconfigured}">
|
||||
<!-- The teaching state: no request ever leaves an unconfigured app
|
||||
— the panel names exactly what is missing. -->
|
||||
<column grow="1" padding="24" main="center" cross="center" label="Setup">
|
||||
<panel padding="24" background="surface" radius="lg" width="520" label="Connect a model">
|
||||
<column gap="12">
|
||||
<text><span weight="bold">Connect a model</span></text>
|
||||
<text size="sm" foreground="text_muted">This app streams OpenAI-compatible chat completions from Vercel AI Gateway. Set AI_GATEWAY_API_KEY and relaunch — the core receives it once, at install, through the env channel.</text>
|
||||
<row gap="8" cross="center">
|
||||
<text size="sm" grow="1">AI_GATEWAY_API_KEY</text>
|
||||
<if test="{keyMissing}">
|
||||
<text size="sm" foreground="destructive">missing</text>
|
||||
</if>
|
||||
<else>
|
||||
<text size="sm" foreground="success">set</text>
|
||||
</else>
|
||||
</row>
|
||||
<text size="sm" foreground="text_muted">The default model is openai/gpt-5.6-luna. Once connected, use the prompt's model selector for Luna, Terra, or Sol, or set NATIVE_SDK_CHAT_MODEL to another creator/model id from the Gateway catalog.</text>
|
||||
</column>
|
||||
</panel>
|
||||
</column>
|
||||
</if>
|
||||
<else>
|
||||
<if test="{emptyConversation}">
|
||||
<column grow="1" padding="24" gap="12" main="center" cross="center" label="Empty conversation">
|
||||
<text size="display" text-alignment="center"><span weight="bold">What can I help with?</span></text>
|
||||
<text size="lg" foreground="text_muted" text-alignment="center">Ask a question, write code, or explore ideas.</text>
|
||||
</column>
|
||||
</if>
|
||||
<else>
|
||||
<scroll grow="1" label="Conversation" value="{chatScrollTop}" on-scroll="chat_scrolled">
|
||||
<!-- Bottom breathing room belongs to the scrolling history,
|
||||
so it is present only at the tail instead of becoming a
|
||||
permanent gutter above the prompt. -->
|
||||
<column padding="24" label="Conversation history">
|
||||
<column gap="10" label="Conversation entries">
|
||||
<for each="turnRows" as="t" key="id">
|
||||
<if test="{t.user}">
|
||||
<row key="{t.id}" gap="8" label="You said">
|
||||
<spacer grow="1" min-width="64" />
|
||||
<bubble variant="primary">
|
||||
<text wrap="true">{t.text}</text>
|
||||
</bubble>
|
||||
</row>
|
||||
</if>
|
||||
<else>
|
||||
<row key="{t.id}" gap="8" label="The model said">
|
||||
<text wrap="true" grow="1">{t.text}</text>
|
||||
</row>
|
||||
</else>
|
||||
</for>
|
||||
<if test="{sending}">
|
||||
<row gap="8" label="Reply pending">
|
||||
<if test="{waitingForFirstToken}">
|
||||
<text grow="1" foreground="text_muted">…</text>
|
||||
</if>
|
||||
<else>
|
||||
<text wrap="true" grow="1">{pendingReplyLabel}</text>
|
||||
</else>
|
||||
</row>
|
||||
</if>
|
||||
<if test="{failed}">
|
||||
<panel padding="12" background="surface" radius="lg" label="Request failed">
|
||||
<row gap="10" cross="center">
|
||||
<icon name="alert" width="14" height="14" foreground="destructive" />
|
||||
<column gap="2" grow="1">
|
||||
<text size="sm" foreground="destructive">Request failed</text>
|
||||
<text size="sm" foreground="text_muted">{failReasonLabel}</text>
|
||||
</column>
|
||||
<button size="sm" variant="ghost" icon="refresh-cw" on-press="retry" label="Retry request">Retry</button>
|
||||
</row>
|
||||
</panel>
|
||||
</if>
|
||||
</column>
|
||||
<!-- A real flow child extends the scrollable content beyond
|
||||
the last wrapped line; container padding alone can stay
|
||||
pinned to the viewport when descendants overflow it. -->
|
||||
<spacer height="24" label="Conversation tail padding" />
|
||||
</column>
|
||||
</scroll>
|
||||
</else>
|
||||
<!-- No top inset: scrolling content meets the prompt border.
|
||||
Side and bottom insets remain as explicit siblings. -->
|
||||
<column label="Composer">
|
||||
<row>
|
||||
<spacer width="12" />
|
||||
<input-group grow="1" height="96" label="Prompt composer">
|
||||
<textarea text="{draftText}" placeholder="Message the model…" autofocus="{promptAutofocus}" submit-on-enter="true" on-input="draft_edit" on-submit="send" label="Message" />
|
||||
<input-group-actions>
|
||||
<stack width="148">
|
||||
<select size="sm" width="148" on-press="toggle_model_picker" label="Model selector">{modelNameLabel}</select>
|
||||
<if test="{modelPickerOpen}">
|
||||
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_model_picker" label="Models">
|
||||
<menu-item on-press="pick_model_luna" selected="{modelIsLuna}">GPT-5.6 Luna</menu-item>
|
||||
<menu-item on-press="pick_model_terra" selected="{modelIsTerra}">GPT-5.6 Terra</menu-item>
|
||||
<menu-item on-press="pick_model_sol" selected="{modelIsSol}">GPT-5.6 Sol</menu-item>
|
||||
</dropdown-menu>
|
||||
</if>
|
||||
</stack>
|
||||
<spacer grow="1" />
|
||||
<if test="{sending}">
|
||||
<button size="icon" variant="primary" icon="x" on-press="stop" label="Stop generating"></button>
|
||||
</if>
|
||||
<else>
|
||||
<button size="icon" variant="primary" icon="arrow-up" disabled="{sendDisabled}" on-press="send" label="Send message"></button>
|
||||
</else>
|
||||
</input-group-actions>
|
||||
</input-group>
|
||||
<spacer width="12" />
|
||||
</row>
|
||||
<spacer height="12" />
|
||||
</column>
|
||||
</else>
|
||||
</column>
|
||||
</row>
|
||||
</column>
|
||||
@@ -0,0 +1,678 @@
|
||||
// Chatbot core: a streaming chat client for the Vercel AI Gateway's
|
||||
// OpenAI-compatible chat-completions endpoint, authored entirely in the
|
||||
// TypeScript app-core subset. Zero Zig in this tree: the build transpiles
|
||||
// this module and src/api.ts,
|
||||
// src/app.native is the whole view, app.zon the manifest.
|
||||
//
|
||||
// The core is two modules plus two SDK libraries, all under src/:
|
||||
//
|
||||
// core.ts (this file) Model, Msg, update, the wiring channels, and
|
||||
// every exported binding helper — the entry module is the
|
||||
// app's public face (markup and node both see its exports)
|
||||
// api.ts the chat-completions wire format in pure bytes: request
|
||||
// encoding and SSE parsing (choices[0].delta.content and
|
||||
// error.message — exactly the fields the app reads)
|
||||
// @native-sdk/core/text the SDK's byte-splice text engine, transpiled
|
||||
// in for the composer's caret/selection/IME fidelity
|
||||
// @native-sdk/core/events the scroll and hidden-titlebar geometry
|
||||
// records used by the markup's controlled host channels
|
||||
//
|
||||
// The whole network surface is ONE streaming effect: `Cmd.fetch` on the
|
||||
// "chat" key. Every Gateway SSE line is a Msg, so visible assistant text
|
||||
// grows in committed Model state as tokens arrive. The in-flight
|
||||
// discipline is model-first: `phase === "sending"` blocks every re-send
|
||||
// in update, and the engine rejects a duplicate live streaming key.
|
||||
//
|
||||
// The Gateway endpoint and three composer model choices are fixed and
|
||||
// reviewable below. `NATIVE_SDK_CHAT_MODEL` can override the initial
|
||||
// choice, while
|
||||
// `AI_GATEWAY_API_KEY` arrives through `envMsgs`; both deliveries are
|
||||
// journaled Msgs at install. The core never reads the environment
|
||||
// (NS1005), which is why a recorded conversation replays byte-identically
|
||||
// without either value.
|
||||
|
||||
import { Cmd, asciiBytes, type EnvMsg } from "@native-sdk/core";
|
||||
import {
|
||||
applyTextInputEvent,
|
||||
clampedInsertEvent,
|
||||
type TextEditState,
|
||||
type TextInputEvent,
|
||||
} from "@native-sdk/core/text";
|
||||
// The SDK-provided scroll-state record (the shape markup's on-scroll
|
||||
// matches structurally - imported, so no in-file mirror can drift).
|
||||
import {
|
||||
type ChromeButtons,
|
||||
type ChromeInsets,
|
||||
type ScrollState,
|
||||
} from "@native-sdk/core/events";
|
||||
import {
|
||||
bearerToken,
|
||||
concatAll,
|
||||
encodeChatRequestWithinLimit,
|
||||
parseChatStreamLine,
|
||||
type Bytes,
|
||||
type Turn,
|
||||
} from "./api.ts";
|
||||
|
||||
/// Vercel AI Gateway's OpenAI-compatible Chat Completions REST endpoint.
|
||||
/// Keeping it fixed makes this example specifically a Gateway client;
|
||||
/// only the optional creator/model override and API key are launch
|
||||
/// configuration.
|
||||
const AI_GATEWAY_ENDPOINT = asciiBytes("https://ai-gateway.vercel.sh/v1/chat/completions");
|
||||
|
||||
/// The example works with only a Gateway API key. Luna is the initial
|
||||
/// composer selection; Terra and Sol are the other built-in choices.
|
||||
const DEFAULT_MODEL = asciiBytes("openai/gpt-5.6-luna");
|
||||
const MODEL_TERRA = asciiBytes("openai/gpt-5.6-terra");
|
||||
const MODEL_SOL = asciiBytes("openai/gpt-5.6-sol");
|
||||
const MODEL_LABEL_LUNA = asciiBytes("GPT-5.6 Luna");
|
||||
const MODEL_LABEL_TERRA = asciiBytes("GPT-5.6 Terra");
|
||||
const MODEL_LABEL_SOL = asciiBytes("GPT-5.6 Sol");
|
||||
|
||||
/// The conversation's standing instruction, first in every request's
|
||||
/// message list. One constant, versioned with the app — not model state,
|
||||
/// so replay and the request pins never depend on it drifting.
|
||||
const SYSTEM_PROMPT = asciiBytes(
|
||||
"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.",
|
||||
);
|
||||
|
||||
/// The composer's byte capacity. Outbound encoding always retains this
|
||||
/// newest prompt and prunes older provider context to the fetch bound.
|
||||
const MAX_DRAFT = 4096;
|
||||
|
||||
/// The engine accepts at most 64 KiB of fetch body. The visible Model
|
||||
/// keeps full history; request encoding sends its newest user-led suffix.
|
||||
const MAX_REQUEST_BODY = 64 * 1024;
|
||||
const REQUEST_TOO_LARGE = asciiBytes("the request is too large");
|
||||
|
||||
/// Keep one in-progress answer no larger than the buffered fetch limit
|
||||
/// this example used before streaming. If a provider exceeds it, stop
|
||||
/// the live request and retain the unanswered user turn for Retry.
|
||||
const MAX_REPLY = 262144;
|
||||
|
||||
/// The app's own header is the tall hidden-inset titlebar. The host may
|
||||
/// report a larger top inset, so chrome_changed keeps this as a floor.
|
||||
const HEADER_NATURAL_HEIGHT = 52;
|
||||
|
||||
/// Assigning the scroll binding a value past the content clamps to the
|
||||
/// bottom. Alternating the two out-of-range values makes every token a
|
||||
/// source-side move even when no host scroll echo arrives between rebuilds.
|
||||
const SCROLL_BOTTOM = 1000000;
|
||||
|
||||
// -------------------------------------------------------------- composer
|
||||
// The fixed-capacity editor state for the message field: the SDK text
|
||||
// engine does the byte splicing; this wrapper is the app's flat committed
|
||||
// shape for it (compStart -1 = no composition). Immutable: composerApply
|
||||
// returns a new value.
|
||||
|
||||
export interface ComposerDraft {
|
||||
readonly bytes: Bytes;
|
||||
readonly anchor: number;
|
||||
readonly focus: number;
|
||||
readonly compStart: number; // -1 when no composition
|
||||
readonly compEnd: number;
|
||||
}
|
||||
|
||||
function composerInit(): ComposerDraft {
|
||||
return { bytes: new Uint8Array(0), anchor: 0, focus: 0, compStart: -1, compEnd: -1 };
|
||||
}
|
||||
|
||||
function composerState(d: ComposerDraft): TextEditState {
|
||||
return {
|
||||
text: d.bytes,
|
||||
selection: { anchor: d.anchor, focus: d.focus },
|
||||
composition: d.compStart >= 0 ? { start: d.compStart, end: d.compEnd } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function composerApply(d: ComposerDraft, event: TextInputEvent): ComposerDraft {
|
||||
const state = composerState(d);
|
||||
const next = applyTextInputEvent(state, event, MAX_DRAFT);
|
||||
if (next === null) {
|
||||
// Over-capacity: clamp an insert to the bytes that fit (refuse-whole
|
||||
// for everything else) — the runtime TextBuffer's contract.
|
||||
const clamped = clampedInsertEvent(state, event, MAX_DRAFT);
|
||||
if (clamped === null) return d;
|
||||
const nextClamped = applyTextInputEvent(state, clamped, MAX_DRAFT);
|
||||
if (nextClamped === null) return d;
|
||||
// Composition bounds land in i64-classed slots: bind them, guard
|
||||
// the range (an ordered comparison excludes NaN), and state
|
||||
// wholeness with Math.trunc at the write; -1 stays the no-composition
|
||||
// sentinel.
|
||||
const clampedStart = nextClamped.composition !== null ? nextClamped.composition.start : -1;
|
||||
const clampedEnd = nextClamped.composition !== null ? nextClamped.composition.end : -1;
|
||||
return {
|
||||
bytes: nextClamped.text,
|
||||
anchor: nextClamped.selection.anchor,
|
||||
focus: nextClamped.selection.focus,
|
||||
compStart: clampedStart >= -1 && clampedStart <= 9007199254740991 ? Math.trunc(clampedStart) : -1,
|
||||
compEnd: clampedEnd >= -1 && clampedEnd <= 9007199254740991 ? Math.trunc(clampedEnd) : -1,
|
||||
};
|
||||
}
|
||||
const nextStart = next.composition !== null ? next.composition.start : -1;
|
||||
const nextEnd = next.composition !== null ? next.composition.end : -1;
|
||||
return {
|
||||
bytes: next.text,
|
||||
anchor: next.selection.anchor,
|
||||
focus: next.selection.focus,
|
||||
compStart: nextStart >= -1 && nextStart <= 9007199254740991 ? Math.trunc(nextStart) : -1,
|
||||
compEnd: nextEnd >= -1 && nextEnd <= 9007199254740991 ? Math.trunc(nextEnd) : -1,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ model
|
||||
|
||||
export type Phase = "idle" | "sending" | "failed";
|
||||
|
||||
export interface Model {
|
||||
/// The conversation, oldest first — user and assistant turns alike.
|
||||
/// Committed state, so record→replay carries the whole conversation.
|
||||
readonly turns: readonly Turn[];
|
||||
readonly nextId: number;
|
||||
/// The request lifecycle: `sending` is the in-flight guard (every
|
||||
/// re-send path checks it), `failed` keeps the history and shows the
|
||||
/// reason until the next send.
|
||||
readonly phase: Phase;
|
||||
/// Why the last request failed: the transport reason (`timed_out`,
|
||||
/// `connect_failed`, ...), the Gateway's own error.message, or the
|
||||
/// HTTP status line — never empty in the failed phase.
|
||||
readonly failReason: Bytes;
|
||||
/// The assistant text received so far for the live request. It is
|
||||
/// rendered immediately but joins `turns` only after a clean terminal.
|
||||
readonly pendingReply: Bytes;
|
||||
/// The Gateway's explicit `data: [DONE]` marker. A clean HTTP EOF
|
||||
/// without it is treated as a truncated protocol response.
|
||||
readonly streamDone: boolean;
|
||||
readonly draft: ComposerDraft;
|
||||
/// The Gateway creator/model id and API key. The model starts at the
|
||||
/// example default and can be replaced by the optional env delivery;
|
||||
/// the app teaches setup until the key arrives.
|
||||
readonly modelName: Bytes;
|
||||
/// The prompt group's model picker is ordinary model-owned UI state. It is
|
||||
/// closed on selection and when a request starts.
|
||||
readonly modelPickerOpen: boolean;
|
||||
/// Autofocus is edge-triggered. Opening the picker lowers this bit;
|
||||
/// choosing a model raises it again so focus returns to the textarea.
|
||||
readonly promptAutofocus: boolean;
|
||||
readonly apiKey: Bytes;
|
||||
/// The conversation scroll offset, echoed from markup's `on-scroll`
|
||||
/// and pushed past the content on every new turn (the clamp lands it
|
||||
/// at the bottom) — the controlled-scroll shape.
|
||||
readonly chatScrollTop: number;
|
||||
/// Alternates the two out-of-range tail requests so every streamed
|
||||
/// delta remains a source-side scroll move even without a host echo.
|
||||
readonly scrollPulse: boolean;
|
||||
/// Hidden-titlebar geometry delivered before the first view build.
|
||||
/// chromeLeading keeps controls clear of the macOS traffic lights;
|
||||
/// headerHeight follows the host's titlebar band with a 52pt floor.
|
||||
readonly chromeLeading: number;
|
||||
readonly headerHeight: number;
|
||||
}
|
||||
|
||||
export function initialModel(): Model {
|
||||
return {
|
||||
turns: [],
|
||||
nextId: 1,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
draft: composerInit(),
|
||||
modelName: DEFAULT_MODEL,
|
||||
modelPickerOpen: false,
|
||||
promptAutofocus: true,
|
||||
apiKey: new Uint8Array(0),
|
||||
chatScrollTop: 0,
|
||||
scrollPulse: false,
|
||||
chromeLeading: 0,
|
||||
headerHeight: HEADER_NATURAL_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- msg
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "draft_edit"; readonly edit: TextInputEvent }
|
||||
/// The send gesture: the composer's Enter (markup `on-submit`) and the
|
||||
/// Send button dispatch the same arm.
|
||||
| { readonly kind: "send" }
|
||||
/// Cancel the live keyed request, keeping any assistant text that has
|
||||
/// already arrived as the stopped response.
|
||||
| { readonly kind: "stop" }
|
||||
/// Re-issue the failed request over the history as it stands (the
|
||||
/// unanswered user turn is already the last entry).
|
||||
| { readonly kind: "retry" }
|
||||
| { readonly kind: "clear" }
|
||||
| { readonly kind: "toggle_model_picker" }
|
||||
| { readonly kind: "close_model_picker" }
|
||||
| { readonly kind: "pick_model_sol" }
|
||||
| { readonly kind: "pick_model_luna" }
|
||||
| { readonly kind: "pick_model_terra" }
|
||||
/// One complete SSE/body line from the Gateway.
|
||||
| { readonly kind: "chat_line"; readonly line: Bytes }
|
||||
/// The delivered streaming response's terminal HTTP status.
|
||||
| { readonly kind: "chat_done"; readonly status: number }
|
||||
/// The transport failure — the fetch err arm's machine-readable reason.
|
||||
| { readonly kind: "chat_failed"; readonly reason: Bytes }
|
||||
| { readonly kind: "chat_scrolled"; readonly scroll: ScrollState }
|
||||
| {
|
||||
readonly kind: "chrome_changed";
|
||||
readonly insets: ChromeInsets;
|
||||
readonly buttons: ChromeButtons;
|
||||
readonly tabsProjected: boolean;
|
||||
}
|
||||
| { readonly kind: "model_set"; readonly value: Bytes }
|
||||
| { readonly kind: "key_set"; readonly value: Bytes };
|
||||
|
||||
// --------------------------------------------------- host-event channels
|
||||
|
||||
/// The launch configuration channel: each variable present at launch
|
||||
/// dispatches one journaled Msg right after boot. The URL and default
|
||||
/// model are fixed above; the model variable is an optional override,
|
||||
/// and no key exists in this tree.
|
||||
export const envMsgs: readonly EnvMsg<Msg>[] = [
|
||||
{ env: "NATIVE_SDK_CHAT_MODEL", msg: "model_set" },
|
||||
{ env: "AI_GATEWAY_API_KEY", msg: "key_set" },
|
||||
];
|
||||
|
||||
/// The tall hidden-inset titlebar geometry is delivered before the first
|
||||
/// view build and whenever the window chrome changes.
|
||||
export const chromeMsg = "chrome_changed";
|
||||
|
||||
/// Update-only state: host-fired Msg arms and the fields markup reads
|
||||
/// through the exported derived helpers instead of directly.
|
||||
export const viewUnbound = [
|
||||
"chat_line",
|
||||
"chat_done",
|
||||
"chat_failed",
|
||||
"chrome_changed",
|
||||
"model_set",
|
||||
"key_set",
|
||||
"turns",
|
||||
"nextId",
|
||||
"phase",
|
||||
"failReason",
|
||||
"pendingReply",
|
||||
"streamDone",
|
||||
"draft",
|
||||
"modelName",
|
||||
"apiKey",
|
||||
"scrollPulse",
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------- derived
|
||||
|
||||
function isConfigured(model: Model): boolean {
|
||||
return model.apiKey.length > 0;
|
||||
}
|
||||
|
||||
/// Composer whitespace includes LF, unlike the line-parser helper: a
|
||||
/// Shift+Enter-only draft must not issue a blank request.
|
||||
function trimComposerWhitespace(text: Bytes): Bytes {
|
||||
let start = 0;
|
||||
let end = text.length;
|
||||
while (start < end && isAsciiWhitespace(text[start])) start += 1;
|
||||
while (end > start && isAsciiWhitespace(text[end - 1])) end -= 1;
|
||||
return text.subarray(start, end);
|
||||
}
|
||||
|
||||
function isAsciiWhitespace(byte: number): boolean {
|
||||
return byte === 0x20 || (byte >= 0x09 && byte <= 0x0d);
|
||||
}
|
||||
|
||||
/// The teaching state: the Gateway key is missing, so the app can only
|
||||
/// explain how to connect it — and issues zero requests.
|
||||
export function unconfigured(model: Model): boolean {
|
||||
return !isConfigured(model);
|
||||
}
|
||||
|
||||
export function keyMissing(model: Model): boolean {
|
||||
return model.apiKey.length === 0;
|
||||
}
|
||||
|
||||
export function sending(model: Model): boolean {
|
||||
return model.phase === "sending";
|
||||
}
|
||||
|
||||
export function failed(model: Model): boolean {
|
||||
return model.phase === "failed";
|
||||
}
|
||||
|
||||
export function failReasonLabel(model: Model): Bytes {
|
||||
return model.failReason;
|
||||
}
|
||||
|
||||
export function pendingReplyLabel(model: Model): Bytes {
|
||||
return model.pendingReply;
|
||||
}
|
||||
|
||||
export function waitingForFirstToken(model: Model): boolean {
|
||||
return model.phase === "sending" && model.pendingReply.length === 0;
|
||||
}
|
||||
|
||||
export function draftText(model: Model): Bytes {
|
||||
return model.draft.bytes;
|
||||
}
|
||||
|
||||
export function emptyConversation(model: Model): boolean {
|
||||
return model.turns.length === 0;
|
||||
}
|
||||
|
||||
export function sendDisabled(model: Model): boolean {
|
||||
return !isConfigured(model);
|
||||
}
|
||||
|
||||
export function modelNameLabel(model: Model): Bytes {
|
||||
if (sameBytes(model.modelName, DEFAULT_MODEL)) return MODEL_LABEL_LUNA;
|
||||
if (sameBytes(model.modelName, MODEL_TERRA)) return MODEL_LABEL_TERRA;
|
||||
if (sameBytes(model.modelName, MODEL_SOL)) return MODEL_LABEL_SOL;
|
||||
return model.modelName;
|
||||
}
|
||||
|
||||
function sameBytes(left: Bytes, right: Bytes): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
if (left[i] !== right[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function modelIsSol(model: Model): boolean {
|
||||
return sameBytes(model.modelName, MODEL_SOL);
|
||||
}
|
||||
|
||||
export function modelIsLuna(model: Model): boolean {
|
||||
return sameBytes(model.modelName, DEFAULT_MODEL);
|
||||
}
|
||||
|
||||
export function modelIsTerra(model: Model): boolean {
|
||||
return sameBytes(model.modelName, MODEL_TERRA);
|
||||
}
|
||||
|
||||
/// One conversation row for markup's `for each`: the role flag picks the
|
||||
/// user-bubble or plain-assistant-text presentation.
|
||||
export interface TurnRow {
|
||||
readonly id: number;
|
||||
readonly user: boolean;
|
||||
readonly text: Bytes;
|
||||
}
|
||||
|
||||
export function turnRows(model: Model): readonly TurnRow[] {
|
||||
return model.turns.map((t) => ({ id: t.id, user: t.role === "user", text: t.text }));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- update
|
||||
|
||||
export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "draft_edit":
|
||||
return [{ ...model, draft: composerApply(model.draft, msg.edit) }, Cmd.none];
|
||||
case "toggle_model_picker":
|
||||
return [{
|
||||
...model,
|
||||
modelPickerOpen: !model.modelPickerOpen,
|
||||
promptAutofocus: model.modelPickerOpen ? model.promptAutofocus : false,
|
||||
}, Cmd.none];
|
||||
case "close_model_picker":
|
||||
if (!model.modelPickerOpen) return [model, Cmd.none];
|
||||
return [{ ...model, modelPickerOpen: false }, Cmd.none];
|
||||
case "pick_model_sol":
|
||||
return [{ ...model, modelName: MODEL_SOL, modelPickerOpen: false, promptAutofocus: true }, Cmd.none];
|
||||
case "pick_model_luna":
|
||||
return [{ ...model, modelName: DEFAULT_MODEL, modelPickerOpen: false, promptAutofocus: true }, Cmd.none];
|
||||
case "pick_model_terra":
|
||||
return [{ ...model, modelName: MODEL_TERRA, modelPickerOpen: false, promptAutofocus: true }, Cmd.none];
|
||||
case "send": {
|
||||
// The in-flight guard: one request at a time, by model state — a
|
||||
// second send while one is out is a no-op, so the "chat" key can
|
||||
// never collide at the engine.
|
||||
if (!isConfigured(model) || model.phase === "sending") return [model, Cmd.none];
|
||||
const text = trimComposerWhitespace(model.draft.bytes);
|
||||
if (text.length === 0) return [model, Cmd.none];
|
||||
const turns: readonly Turn[] = [...model.turns, { id: model.nextId, role: "user", text: text }];
|
||||
const body = encodeChatRequestWithinLimit(model.modelName, SYSTEM_PROMPT, turns, MAX_REQUEST_BODY);
|
||||
const next: Model = {
|
||||
...model,
|
||||
turns: turns,
|
||||
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
|
||||
phase: "sending",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
draft: composerInit(),
|
||||
modelPickerOpen: false,
|
||||
promptAutofocus: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
};
|
||||
if (body.length === 0) {
|
||||
return [{ ...next, phase: "failed", failReason: REQUEST_TOO_LARGE }, Cmd.none];
|
||||
}
|
||||
return [
|
||||
next,
|
||||
Cmd.fetch(
|
||||
{
|
||||
url: AI_GATEWAY_ENDPOINT,
|
||||
method: "POST",
|
||||
// The bearer token is a RUNTIME header value (built from the
|
||||
// launch-supplied key); header names stay compile-time.
|
||||
headers: {
|
||||
accept: "text/event-stream",
|
||||
authorization: bearerToken(model.apiKey),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: body,
|
||||
timeoutMs: 120000,
|
||||
maxLineBytes: 65536,
|
||||
},
|
||||
{ key: "chat", line: "chat_line", ok: "chat_done", err: "chat_failed" },
|
||||
),
|
||||
];
|
||||
}
|
||||
case "retry": {
|
||||
// Re-send the conversation as it stands: only from the failed
|
||||
// state, and only when the last turn is the unanswered user turn.
|
||||
if (model.phase !== "failed" || !isConfigured(model)) return [model, Cmd.none];
|
||||
if (model.turns.length === 0) return [model, Cmd.none];
|
||||
if (model.turns[model.turns.length - 1].role !== "user") return [model, Cmd.none];
|
||||
const body = encodeChatRequestWithinLimit(model.modelName, SYSTEM_PROMPT, model.turns, MAX_REQUEST_BODY);
|
||||
if (body.length === 0) return [{ ...model, failReason: REQUEST_TOO_LARGE }, Cmd.none];
|
||||
return [
|
||||
{
|
||||
...model,
|
||||
phase: "sending",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
promptAutofocus: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
},
|
||||
Cmd.fetch(
|
||||
{
|
||||
url: AI_GATEWAY_ENDPOINT,
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "text/event-stream",
|
||||
authorization: bearerToken(model.apiKey),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: body,
|
||||
timeoutMs: 120000,
|
||||
maxLineBytes: 65536,
|
||||
},
|
||||
{ key: "chat", line: "chat_line", ok: "chat_done", err: "chat_failed" },
|
||||
),
|
||||
];
|
||||
}
|
||||
case "stop": {
|
||||
if (model.phase !== "sending") return [model, Cmd.none];
|
||||
if (model.pendingReply.length === 0) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
promptAutofocus: true,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.cancel("chat")];
|
||||
}
|
||||
return [{
|
||||
...model,
|
||||
turns: [...model.turns, { id: model.nextId, role: "assistant", text: model.pendingReply }],
|
||||
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
promptAutofocus: true,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.cancel("chat")];
|
||||
}
|
||||
case "clear": {
|
||||
const next: Model = {
|
||||
...model,
|
||||
turns: [],
|
||||
nextId: 1,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
draft: composerInit(),
|
||||
modelPickerOpen: false,
|
||||
chatScrollTop: 0,
|
||||
scrollPulse: false,
|
||||
};
|
||||
// Starting a new chat is always available. If a reply is live,
|
||||
// close its keyed stream; the resulting cancelled terminal is
|
||||
// stale once phase is idle and is deliberately ignored below.
|
||||
if (model.phase === "sending") return [next, Cmd.cancel("chat")];
|
||||
return [next, Cmd.none];
|
||||
}
|
||||
case "chat_line": {
|
||||
// The "chat" key carries exactly one live request and the sending
|
||||
// guard blocks re-sends, so a line outside the sending phase
|
||||
// can only be stale — drop it rather than corrupt the history.
|
||||
if (model.phase !== "sending") return [model, Cmd.none];
|
||||
if (model.streamDone) return [model, Cmd.none];
|
||||
const event = parseChatStreamLine(msg.line);
|
||||
switch (event.kind) {
|
||||
case "ignore":
|
||||
return [model, Cmd.none];
|
||||
case "delta": {
|
||||
if (model.pendingReply.length + event.text.length > MAX_REPLY) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: asciiBytes("the streamed reply exceeded 256 KiB"),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.cancel("chat")];
|
||||
}
|
||||
return [{
|
||||
...model,
|
||||
pendingReply: concatAll([model.pendingReply, event.text]),
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
case "done":
|
||||
return [{ ...model, streamDone: true }, Cmd.none];
|
||||
case "error":
|
||||
// Error bodies can be SSE data or plain JSON lines. Preserve
|
||||
// the Gateway's own message for the terminal status handler.
|
||||
return [{ ...model, failReason: event.message }, Cmd.none];
|
||||
}
|
||||
}
|
||||
case "chat_done": {
|
||||
if (model.phase !== "sending") return [model, Cmd.none];
|
||||
if (model.failReason.length > 0) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
if (msg.status < 200 || msg.status >= 300) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: asciiBytes(`Vercel AI Gateway answered HTTP ${msg.status}`),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
if (!model.streamDone) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: asciiBytes("the response stream ended before [DONE]"),
|
||||
pendingReply: new Uint8Array(0),
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
if (model.pendingReply.length === 0) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: asciiBytes("the response stream produced no text"),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
return [{
|
||||
...model,
|
||||
turns: [...model.turns, { id: model.nextId, role: "assistant", text: model.pendingReply }],
|
||||
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
|
||||
phase: "idle",
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
case "chat_failed":
|
||||
// The transport reason is machine-readable (`timed_out`,
|
||||
// `connect_failed`, `truncated`, ...) — shown as-is, never silence.
|
||||
if (model.phase !== "sending") return [model, Cmd.none];
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: msg.reason,
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
case "chat_scrolled":
|
||||
// The controlled-scroll echo: the applied offset lands in the
|
||||
// model, so the next rebuild's `value` binding never fights the
|
||||
// runtime.
|
||||
return [{ ...model, chatScrollTop: msg.scroll.offsetY }, Cmd.none];
|
||||
case "chrome_changed":
|
||||
return [{
|
||||
...model,
|
||||
chromeLeading: msg.insets.left,
|
||||
headerHeight: Math.max(HEADER_NATURAL_HEIGHT, msg.insets.top),
|
||||
}, Cmd.none];
|
||||
case "model_set":
|
||||
// An explicitly empty optional override does not erase the
|
||||
// built-in default.
|
||||
if (msg.value.length === 0) return [model, Cmd.none];
|
||||
return [{ ...model, modelName: msg.value }, Cmd.none];
|
||||
case "key_set":
|
||||
return [{ ...model, apiKey: msg.value }, Cmd.none];
|
||||
}
|
||||
}
|
||||
@@ -1506,7 +1506,7 @@ test "the empty and loaded views expose the picker, tree, and highlighted code s
|
||||
);
|
||||
|
||||
const editor = findByText(tree.root, .textarea, source).?;
|
||||
try testing.expect(editor.code_editor);
|
||||
try testing.expect(editor.runtime_flags.code_editor);
|
||||
try testing.expectEqual(@as(usize, 1), editor.spans.len);
|
||||
try testing.expectEqual(native_sdk.code.Language.tsx, editor.code_language);
|
||||
try testing.expectEqual(native_sdk.geometry.InsetsF{}, editor.layout.padding);
|
||||
|
||||
@@ -67,6 +67,13 @@ export interface FetchRoute<M extends Msgish> {
|
||||
readonly err: M["kind"];
|
||||
}
|
||||
|
||||
export interface FetchStreamRoute<M extends Msgish> {
|
||||
readonly key?: string;
|
||||
readonly line: M["kind"];
|
||||
readonly ok: M["kind"];
|
||||
readonly err: M["kind"];
|
||||
}
|
||||
|
||||
export interface SpawnRoute<M extends Msgish> {
|
||||
readonly key?: string;
|
||||
readonly stdin?: Uint8Array;
|
||||
@@ -170,6 +177,10 @@ export interface FetchSpec {
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface FetchStreamSpec extends FetchSpec {
|
||||
readonly maxLineBytes?: number;
|
||||
}
|
||||
|
||||
export interface NotificationSpec {
|
||||
readonly title: Uint8Array;
|
||||
readonly subtitle?: Uint8Array;
|
||||
@@ -220,6 +231,19 @@ export type CmdData =
|
||||
readonly headers: readonly { readonly name: string; readonly value: string | Uint8Array }[];
|
||||
readonly body: Uint8Array;
|
||||
}
|
||||
| {
|
||||
readonly op: "fetch_stream";
|
||||
readonly key: string;
|
||||
readonly lineKind: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly method: FetchMethod;
|
||||
readonly timeoutMs: number;
|
||||
readonly maxLineBytes: number;
|
||||
readonly url: Uint8Array;
|
||||
readonly headers: readonly { readonly name: string; readonly value: string | Uint8Array }[];
|
||||
readonly body: Uint8Array;
|
||||
}
|
||||
| { readonly op: "clip_write"; readonly bytes: Uint8Array }
|
||||
| { readonly op: "clip_read"; readonly key: string; readonly okKind: string; readonly errKind: string }
|
||||
| { readonly op: "show_notification"; readonly title: Uint8Array; readonly subtitle: Uint8Array; readonly body: Uint8Array }
|
||||
@@ -391,12 +415,30 @@ export const Cmd = {
|
||||
return { op: "write_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path, bytes };
|
||||
},
|
||||
|
||||
fetch(spec: FetchSpec, route: { readonly key?: string; readonly ok: string; readonly err: string }): CmdData {
|
||||
fetch(
|
||||
spec: FetchStreamSpec,
|
||||
route: { readonly key?: string; readonly line?: string; readonly ok: string; readonly err: string },
|
||||
): CmdData {
|
||||
const names = Object.keys(spec.headers ?? {}).sort();
|
||||
const headers: { readonly name: string; readonly value: string | Uint8Array }[] = [];
|
||||
for (const n of names) {
|
||||
headers.push({ name: n, value: spec.headers![n]! });
|
||||
}
|
||||
if (route.line !== undefined) {
|
||||
return {
|
||||
op: "fetch_stream",
|
||||
key: route.key ?? "",
|
||||
lineKind: route.line,
|
||||
okKind: route.ok,
|
||||
errKind: route.err,
|
||||
method: spec.method ?? "GET",
|
||||
timeoutMs: spec.timeoutMs ?? 0,
|
||||
maxLineBytes: spec.maxLineBytes ?? 0,
|
||||
url: spec.url,
|
||||
headers: headers,
|
||||
body: spec.body ?? new Uint8Array(0),
|
||||
};
|
||||
}
|
||||
return {
|
||||
op: "fetch",
|
||||
key: route.key ?? "",
|
||||
|
||||
Vendored
+27
-1
@@ -121,6 +121,12 @@ export interface FetchRoute<M extends Msgish> {
|
||||
readonly ok: FetchedKind<M>;
|
||||
readonly err: BytesKind<M>;
|
||||
}
|
||||
export interface FetchStreamRoute<M extends Msgish> {
|
||||
readonly key?: string;
|
||||
readonly line: BytesKind<M>;
|
||||
readonly ok: TimestampKind<M>;
|
||||
readonly err: BytesKind<M>;
|
||||
}
|
||||
export interface SpawnRoute<M extends Msgish> {
|
||||
readonly key?: string;
|
||||
readonly stdin?: Uint8Array;
|
||||
@@ -174,6 +180,9 @@ export interface FetchSpec {
|
||||
readonly body?: Uint8Array;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
export interface FetchStreamSpec extends FetchSpec {
|
||||
readonly maxLineBytes?: number;
|
||||
}
|
||||
export interface NotificationSpec {
|
||||
readonly title: Uint8Array;
|
||||
readonly subtitle?: Uint8Array;
|
||||
@@ -230,6 +239,21 @@ export type Cmd<M extends Msgish> = {
|
||||
readonly value: string | Uint8Array;
|
||||
}[];
|
||||
readonly body: Uint8Array;
|
||||
} | {
|
||||
readonly op: "fetch_stream";
|
||||
readonly key: string;
|
||||
readonly lineKind: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly method: FetchMethod;
|
||||
readonly timeoutMs: number;
|
||||
readonly maxLineBytes: number;
|
||||
readonly url: Uint8Array;
|
||||
readonly headers: readonly {
|
||||
readonly name: string;
|
||||
readonly value: string | Uint8Array;
|
||||
}[];
|
||||
readonly body: Uint8Array;
|
||||
} | {
|
||||
readonly op: "clip_write";
|
||||
readonly bytes: Uint8Array;
|
||||
@@ -348,6 +372,8 @@ export type Cmd<M extends Msgish> = {
|
||||
export declare function hostRecordBytes(payload: HostRecord): Uint8Array;
|
||||
declare function hostCmd(name: string, payload: Uint8Array | HostRecord): Cmd<never>;
|
||||
declare function hostCmd(name: string, ...args: readonly number[]): Cmd<never>;
|
||||
declare function fetchCmd<M extends Msgish>(spec: FetchSpec, route: FetchRoute<M>): Cmd<M>;
|
||||
declare function fetchCmd<M extends Msgish>(spec: FetchStreamSpec, route: FetchStreamRoute<M>): Cmd<M>;
|
||||
export declare const Cmd: {
|
||||
none: Cmd<never>;
|
||||
persist(): Cmd<never>;
|
||||
@@ -357,7 +383,7 @@ export declare const Cmd: {
|
||||
cancel(key: string): Cmd<never>;
|
||||
readFile<M extends Msgish>(path: Uint8Array, route: RequestRoute<M>): Cmd<M>;
|
||||
writeFile<M extends Msgish>(path: Uint8Array, bytes: Uint8Array, route: WriteRoute<M>): Cmd<M>;
|
||||
fetch<M extends Msgish>(spec: FetchSpec, route: FetchRoute<M>): Cmd<M>;
|
||||
fetch: typeof fetchCmd;
|
||||
clipboardWrite(bytes: Uint8Array): Cmd<never>;
|
||||
clipboardRead<M extends Msgish>(route: RequestRoute<M>): Cmd<M>;
|
||||
showNotification(spec: NotificationSpec): Cmd<never>;
|
||||
|
||||
+93
-28
@@ -34,10 +34,9 @@
|
||||
// Cmd.cancel(key) drop the in-flight keyed effect — request,
|
||||
// readFile/writeFile/fetch/clipboardRead, or
|
||||
// delay — SILENTLY (no terminal arm dispatch).
|
||||
// Aimed at a live spawn it stays LOUD: the
|
||||
// child dies and the err arm runs with
|
||||
// "cancelled" — killing a process is an
|
||||
// observable event
|
||||
// Aimed at a live spawn or streaming fetch it
|
||||
// stays LOUD: the err arm runs with
|
||||
// "cancelled" — ending a stream is observable
|
||||
// Cmd.batch([a, b, ...]) several commands from one dispatch
|
||||
//
|
||||
// The named engine ops (each maps onto the host's effect engine directly;
|
||||
@@ -61,6 +60,13 @@
|
||||
// the reason bytes ("connect_failed",
|
||||
// "tls_failed", "protocol_failed", "timed_out",
|
||||
// "rejected", "truncated")
|
||||
// Cmd.fetch({ ..., maxLineBytes? }, { key?, line, ok, err })
|
||||
// streaming HTTP(S) exchange; each complete
|
||||
// response line dispatches the one-bytes-field
|
||||
// `line` arm, then exactly one terminal follows:
|
||||
// `ok` with the HTTP status as its one number
|
||||
// field, or `err` with the transport reason
|
||||
// (or "truncated" if any line was cut/dropped)
|
||||
// Cmd.clipboardWrite(bytes) fire-and-forget clipboard write
|
||||
// Cmd.clipboardRead({ key?, ok, err })
|
||||
// clipboard read; ok arm carries the text bytes,
|
||||
@@ -232,10 +238,10 @@
|
||||
// The keyed-effect discipline is ONE rule: a keyed effect REPLACES its live
|
||||
// predecessor (the superseded effect's result is dropped — no message), and
|
||||
// Cmd.cancel drops it silently. That holds for request, readFile, writeFile,
|
||||
// fetch, clipboardRead, and delay alike. The ONE exception is a live spawn
|
||||
// key: a duplicate REJECTS the new spawn (err arm "rejected") — a running
|
||||
// subprocess is never killed implicitly; cancel it first. And spawn's cancel
|
||||
// stays loud (err arm "cancelled"): killing a process is an observable event.
|
||||
// buffered fetch, clipboardRead, and delay alike. Live spawn and streaming-
|
||||
// fetch keys are the exceptions: a duplicate REJECTS the new stream (err arm
|
||||
// "rejected") so results from two sources are never spliced together. Cancel
|
||||
// either stream first; its err arm runs with "cancelled".
|
||||
//
|
||||
// `Sub` is the recurring-effects surface: an app may export
|
||||
// `subscriptions(model): Sub<Msg>` returning declarative descriptors the
|
||||
@@ -723,6 +729,18 @@ export interface FetchRoute<M extends Msgish> {
|
||||
readonly err: BytesKind<M>;
|
||||
}
|
||||
|
||||
/// Streaming `Cmd.fetch` routing: each complete response line dispatches the
|
||||
/// `line` arm with its bytes; the one successful terminal dispatches `ok` with
|
||||
/// the HTTP status. A cut/dropped line dispatches `err` with `"truncated"` at
|
||||
/// the terminal; transport failure and cancellation dispatch `err` with their
|
||||
/// machine-readable reason bytes.
|
||||
export interface FetchStreamRoute<M extends Msgish> {
|
||||
readonly key?: string;
|
||||
readonly line: BytesKind<M>;
|
||||
readonly ok: TimestampKind<M>;
|
||||
readonly err: BytesKind<M>;
|
||||
}
|
||||
|
||||
/// `Cmd.spawn` routing, line mode: each stdout line dispatches the optional
|
||||
/// `line` arm (one bytes field; omitted = lines dropped), a clean exit the
|
||||
/// `exit` arm (one number field — the exit code), every other end the `err`
|
||||
@@ -825,6 +843,15 @@ export interface FetchSpec {
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
/// A line-streamed fetch request. `maxLineBytes` overrides the engine's 4 KiB
|
||||
/// per-line default for SSE/NDJSON protocols whose individual records are
|
||||
/// larger; it is bounded by the engine's 256 KiB per-line ceiling. If a line
|
||||
/// is cut or dropped, the stream still ends through `err: "truncated"` rather
|
||||
/// than reporting a successful terminal.
|
||||
export interface FetchStreamSpec extends FetchSpec {
|
||||
readonly maxLineBytes?: number;
|
||||
}
|
||||
|
||||
/// A desktop notification request. Text is bytes so every field may come
|
||||
/// directly from model data; subtitle and body default to empty. Delivery is
|
||||
/// fire-and-forget because the OS may suppress an accepted request through
|
||||
@@ -884,6 +911,19 @@ export type Cmd<M extends Msgish> =
|
||||
readonly headers: readonly { readonly name: string; readonly value: string | Uint8Array }[];
|
||||
readonly body: Uint8Array;
|
||||
}
|
||||
| {
|
||||
readonly op: "fetch_stream";
|
||||
readonly key: string;
|
||||
readonly lineKind: string;
|
||||
readonly okKind: string;
|
||||
readonly errKind: string;
|
||||
readonly method: FetchMethod;
|
||||
readonly timeoutMs: number;
|
||||
readonly maxLineBytes: number;
|
||||
readonly url: Uint8Array;
|
||||
readonly headers: readonly { readonly name: string; readonly value: string | Uint8Array }[];
|
||||
readonly body: Uint8Array;
|
||||
}
|
||||
| { readonly op: "clip_write"; readonly bytes: Uint8Array }
|
||||
| { readonly op: "clip_read"; readonly key: string; readonly okKind: string; readonly errKind: string }
|
||||
| { readonly op: "show_notification"; readonly title: Uint8Array; readonly subtitle: Uint8Array; readonly body: Uint8Array }
|
||||
@@ -1026,6 +1066,48 @@ function hostCmd(name: string, ...rest: readonly (number | Uint8Array | HostReco
|
||||
return { op: "host", name, args: rest as readonly number[] };
|
||||
}
|
||||
|
||||
/// HTTP(S) as effect data. The two-field `{ ok, err }` route buffers the whole
|
||||
/// response. Adding `line` selects line streaming: each complete line arrives
|
||||
/// through that one-bytes-field arm, then `ok` receives the terminal HTTP
|
||||
/// status as its one number payload. A non-2xx status is still `ok` because the
|
||||
/// server delivered a response; cut/dropped stream lines, transport failures,
|
||||
/// and cancellation use `err` (`"truncated"` for stream data loss).
|
||||
function fetchCmd<M extends Msgish>(spec: FetchSpec, route: FetchRoute<M>): Cmd<M>;
|
||||
function fetchCmd<M extends Msgish>(spec: FetchStreamSpec, route: FetchStreamRoute<M>): Cmd<M>;
|
||||
function fetchCmd<M extends Msgish>(
|
||||
spec: FetchStreamSpec,
|
||||
route: FetchRoute<M> | FetchStreamRoute<M>,
|
||||
): Cmd<M> {
|
||||
const names = Object.keys(spec.headers ?? {}).sort();
|
||||
const headers = names.map((n) => ({ name: n, value: spec.headers![n] }));
|
||||
if ("line" in route) {
|
||||
return {
|
||||
op: "fetch_stream",
|
||||
key: route.key ?? "",
|
||||
lineKind: route.line,
|
||||
okKind: route.ok,
|
||||
errKind: route.err,
|
||||
method: spec.method ?? "GET",
|
||||
timeoutMs: spec.timeoutMs ?? 0,
|
||||
maxLineBytes: spec.maxLineBytes ?? 0,
|
||||
url: spec.url,
|
||||
headers,
|
||||
body: spec.body ?? new Uint8Array(0),
|
||||
};
|
||||
}
|
||||
return {
|
||||
op: "fetch",
|
||||
key: route.key ?? "",
|
||||
okKind: route.ok,
|
||||
errKind: route.err,
|
||||
method: spec.method ?? "GET",
|
||||
timeoutMs: spec.timeoutMs ?? 0,
|
||||
url: spec.url,
|
||||
headers,
|
||||
body: spec.body ?? new Uint8Array(0),
|
||||
};
|
||||
}
|
||||
|
||||
export const Cmd = {
|
||||
/// No effects. `return model` is sugar for `return [model, Cmd.none]`.
|
||||
none: { op: "none" } as Cmd<never>,
|
||||
@@ -1066,8 +1148,8 @@ export const Cmd = {
|
||||
|
||||
/// Drop the in-flight keyed effect — request, named engine op, or delay —
|
||||
/// with this key, if any, SILENTLY (neither routing arm is dispatched for
|
||||
/// it). The exception is a live spawn: cancel ends the child and its err
|
||||
/// arm runs with "cancelled" — killing a process is an observable event.
|
||||
/// it). Live spawn and streaming-fetch operations are the exceptions:
|
||||
/// cancel ends the stream and its err arm runs with "cancelled".
|
||||
cancel(key: string): Cmd<never> {
|
||||
return { op: "cancel", key };
|
||||
},
|
||||
@@ -1086,24 +1168,7 @@ export const Cmd = {
|
||||
return { op: "write_file", key: route.key ?? "", okKind: route.ok, errKind: route.err, path, bytes };
|
||||
},
|
||||
|
||||
/// A buffered HTTP(S) exchange. Exactly one terminal Msg: the `ok` arm
|
||||
/// with `{ status, body }` (one number field, one bytes field — a non-2xx
|
||||
/// status is still ok: an HTTP-level error is a delivered response), or
|
||||
/// the `err` arm with the reason bytes.
|
||||
fetch<M extends Msgish>(spec: FetchSpec, route: FetchRoute<M>): Cmd<M> {
|
||||
const names = Object.keys(spec.headers ?? {}).sort();
|
||||
return {
|
||||
op: "fetch",
|
||||
key: route.key ?? "",
|
||||
okKind: route.ok,
|
||||
errKind: route.err,
|
||||
method: spec.method ?? "GET",
|
||||
timeoutMs: spec.timeoutMs ?? 0,
|
||||
url: spec.url,
|
||||
headers: names.map((n) => ({ name: n, value: spec.headers![n] })),
|
||||
body: spec.body ?? new Uint8Array(0),
|
||||
};
|
||||
},
|
||||
fetch: fetchCmd,
|
||||
|
||||
/// Put bytes on the system clipboard, fire-and-forget (an over-bound or
|
||||
/// refused write is dropped — there is no route to report on).
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
// - timer/now/delay arms carry exactly one number payload field (pinned
|
||||
// by tsc), so the harness constructs them shape-directed without
|
||||
// needing the field's name.
|
||||
// Every other effect (files, fetch, clipboard, notifications, spawn, audio,
|
||||
// host commands)
|
||||
// Every other effect (files, buffered/streaming fetch, clipboard,
|
||||
// notifications, spawn, audio, host commands)
|
||||
// is printed as `cmd ...` and NOT performed — feed its result back yourself
|
||||
// as an ordinary Msg line. That is the point: results are plain messages,
|
||||
// and the loop stays deterministic.
|
||||
@@ -161,7 +161,7 @@ function performCmd(cmd: Cmdish): void {
|
||||
if (delays.delete(key)) {
|
||||
say(`cmd cancel ${key} (delay dropped)`);
|
||||
} else {
|
||||
say(`cmd cancel ${key} (not performed here - a live request or named op drops silently; a live spawn ends loudly, err arm "cancelled")`);
|
||||
say(`cmd cancel ${key} (not performed here - a live request or buffered named op drops silently; a live spawn or streaming fetch ends loudly, err arm "cancelled")`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3749,6 +3749,20 @@ export function f(x: number, y: number): number { return pick(x, y, x < y); }`,
|
||||
// event stream — emit-clean in their documented shapes, gated with the
|
||||
// taught rules everywhere else.
|
||||
const streamingCases: Case[] = [
|
||||
{
|
||||
name: "fetch streams route response lines and one terminal HTTP status",
|
||||
src: `
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
${streamMsg}
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "go": return [model, Cmd.fetch(
|
||||
{ url: asciiBytes("https://a.test/events"), method: "POST", headers: { accept: "text/event-stream" }, body: model.out, timeoutMs: 60000, maxLineBytes: 65536 },
|
||||
{ key: "events", line: "line", ok: "done", err: "failed" },
|
||||
)];
|
||||
${streamTail}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "the window verbs emit in their documented shapes",
|
||||
src: `
|
||||
|
||||
@@ -164,6 +164,31 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
assert.equal(doc.update_returns_cmd, true);
|
||||
});
|
||||
|
||||
test("Cmd.fetch accepts a line-stream route with bytes and status arms", () => {
|
||||
const doc = contractOf(`
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
export interface Model { lines: number; status: number; }
|
||||
export type Msg =
|
||||
| { kind: "start" }
|
||||
| { kind: "line"; bytes: Uint8Array }
|
||||
| { kind: "finished"; status: number }
|
||||
| { kind: "failed"; reason: Uint8Array };
|
||||
export function initialModel(): Model { return { lines: 0, status: 0 }; }
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "start": return [model, Cmd.fetch(
|
||||
{ url: asciiBytes("https://example.test/events"), maxLineBytes: 65536 },
|
||||
{ key: "events", line: "line", ok: "finished", err: "failed" },
|
||||
)];
|
||||
case "line": return { ...model, lines: model.lines + 1 };
|
||||
case "finished": return { ...model, status: msg.status };
|
||||
case "failed": return model;
|
||||
}
|
||||
}
|
||||
`);
|
||||
assert.equal(doc.update_returns_cmd, true);
|
||||
});
|
||||
|
||||
test("re-runs reproduce the identity fields exactly, and edits move them", () => {
|
||||
const a = contractOf(smallCore);
|
||||
const b = contractOf(smallCore);
|
||||
|
||||
@@ -265,7 +265,7 @@ When changing app behavior, keep tests in the app's existing authoring tier. Typ
|
||||
|
||||
## Examples to inspect
|
||||
|
||||
- `examples/ai-chat-ts`: a substantial app authored entirely in TypeScript + Native markup, with fetch effects and modules.
|
||||
- `examples/chatbot`: a substantial app authored entirely in TypeScript + Native markup, with streaming fetch effects and modules.
|
||||
- `examples/soundboard-ts`: TypeScript + Native markup port of the full music-player showcase.
|
||||
- `examples/system-monitor-ts`: TypeScript + Native markup app using spawn effects, timers, and tables.
|
||||
- `examples/hello`: smallest lower-level inline HTML/WebView app.
|
||||
|
||||
@@ -17,7 +17,7 @@ The markup compiles to the same widget tree a hand-written `canvas.Ui(Msg)` buil
|
||||
|
||||
Editors highlight `.native` markup well in HTML mode — the default scaffold writes no editor config, so add `.vscode/settings.json` with `"files.associations": {"*.native": "html"}` yourself, or scaffold with `native init --full`, which writes it.
|
||||
|
||||
Start a new app with `native init` (zero-config: `app.zon` + `src/core.ts` + `src/app.native` + assets; the CLI generates the build graph). Use `examples/ai-chat-ts`, `examples/soundboard-ts`, and `examples/system-monitor-ts` as substantial TypeScript references. The `native check|dev|test|build` verbs drive any app directory shaped this way.
|
||||
Start a new app with `native init` (zero-config: `app.zon` + `src/core.ts` + `src/app.native` + assets; the CLI generates the build graph). Use `examples/chatbot`, `examples/soundboard-ts`, and `examples/system-monitor-ts` as substantial TypeScript references. The `native check|dev|test|build` verbs drive any app directory shaped this way.
|
||||
|
||||
## App wiring (Zig cores and extensions only)
|
||||
|
||||
@@ -180,7 +180,7 @@ Automation drives the native path honestly: snapshots list every widget's declar
|
||||
| `text` > `span` | inline styled runs | mixed-style text in ONE wrapped paragraph: span children style runs with `weight="regular\|medium\|bold"`, `mono`, `italic`, `scale` (a positive multiplier on the paragraph's base size — inline headings, hero stats), `underline`, `foreground` (token name); `{bindings}` interpolate inside spans; whitespace between runs collapses to a single space (none = the runs abut); spans do not nest, take no events, and the paragraph announces as one text run — see "Rich text" |
|
||||
| `button`, `toggle-button`, `list-item`, `menu-item`, `toggle`, `switch`, `select`, `avatar` | text-bearing controls | label is the text content; `button`, `toggle-button`, `list-item`, and `menu-item` also take `icon="save"` — a vector icon drawn inline (buttons/toggle-buttons before the label, icon-only when the content is empty: add a `label`; list/menu items as a leading slot), ONE hit target whose icon follows the element's enabled/disabled tint (no overlay stacking, no duplicated `on-press`); tab strips are `toggle-button` children, so tabs get icons this way; `select` shows `placeholder` while empty and dispatches `on-press`; `avatar` renders initials, or a runtime image via `image="{binding}"` (see the Images section) |
|
||||
| `checkbox`, `radio`, `slider`, `progress` | value controls | `checked`, `value` (a 0..1 fraction on slider and progress; progress clamps out-of-range values at render, never an error); the checkbox/radio label rides `text="..."` — these are not text-bearing elements, so text content is a teaching error (`label=` alone names one for accessibility without a visible label); a slider's `value` follows the source when it MOVES (model-driven progress renders every rebuild) and keeps the user's drag while the source replays the same value — use `slider` for seek bars, `progress` for display-only; a markup slider's `on-change` dispatches a PLAIN Msg with no value payload — mirror the applied value into the model with `Options.sync` (the Zig builder's `on_value = Ui.valueMsg(.tag)` does deliver the applied f32) |
|
||||
| `text-field`, `input`, `search-field`, `combobox`, `textarea` | text entry | `placeholder`; edits via `on-input`, enter via `on-submit` on single-line kinds; in a `textarea`, Enter (and Shift+Enter) inserts a newline and `on-submit` dispatches on primary+Enter (cmd on macOS, ctrl elsewhere); `search-field` carries a built-in trailing clear affordance whenever it holds text (press the x, or Escape while focused — both clear through the text-edit path, so `on-input` hears it; no attribute, no external Clear button needed) |
|
||||
| `text-field`, `input`, `search-field`, `combobox`, `textarea` | text entry | `placeholder`; edits via `on-input`, enter via `on-submit` on single-line kinds; in a default `textarea`, Enter (and Shift+Enter) inserts a newline and `on-submit` dispatches on primary+Enter (cmd on macOS, ctrl elsewhere). A chat composer opts into `submit-on-enter="true"`: plain Enter submits, Shift+Enter remains a newline, and the primary chord still submits. `search-field` carries a built-in trailing clear affordance whenever it holds text (press the x, or Escape while focused — both clear through the text-edit path, so `on-input` hears it; no attribute, no external Clear button needed) |
|
||||
| `status-bar` | status bar | text leaf: content only, no children |
|
||||
| `separator`, `spacer` | separator, flexible space | `separator` is axis-aware: a horizontal rule in a `column`, a thin vertical divider in a `row`; give `spacer` a `grow` |
|
||||
| `skeleton`, `spinner` | loading leaves | size `skeleton` with `width`/`height` |
|
||||
@@ -193,13 +193,13 @@ Automation drives the native path honestly: snapshots list every widget's declar
|
||||
| `timeline` > `timeline-item` | composite ledger list | items only inside a timeline (for/if fine); items are leaves — `title` (required), `description`, `meta`, `indicator`, `variant`, `connector="false"` on the last item, `selected`; `on-press` makes the whole item pressable with a trailing chevron |
|
||||
| `chart` > `series` | composite data chart | series only inside a chart, and only series (the set is static — data varies through bindings); each series is a leaf — `values="{binding}"` (required) names a model `[]const f32` iterable, `kind` is `line`/`area`/`bar` (literal), `color` a token name, `label` the semantics name; chart takes `y-min`, `y-max`, `grid-lines`, `baseline`, `x-labels`, `y-labels`, `hover-details`, `stroke-width`, box options, `label` — see "Charts" |
|
||||
| `context-menu` | consumed by its parent | right-click menu on its DIRECT parent (a hit target or an element with `on-press`/`on-hold`); metadata, never a flow child. Children: `menu-item`s (`on-press` required, `disabled` optional, no `icon`) and bare `separator`s, with `if`/`else`/`for` around them. Attribute-less; presents natively where the host has a menu presenter, as an anchored surface elsewhere — see "Context menus" |
|
||||
| `input-group` > `textarea` + `input-group-actions` | composite grouped input | the composer shape: ONE bordered field wrapping exactly one `textarea` (first — document order is focus order) plus an optional `input-group-actions` row of controls inside the same border. The group wears the focus ring for its focused descendant and the textarea's own chrome dissolves automatically, so the whole group reads as one field; the textarea keeps its full behavior (`text`, `placeholder`, `on-input`, `on-submit`, `autofocus`). Group takes `label`, `width`, `height`, `min-width`, `grow`, `key`, `global-key`; the actions row takes `gap` and holds ordinary elements (`if`/`else`/`for` work — swap send for stop while streaming) — put a `<spacer grow="1"/>` between leading and trailing controls (`Ui.inputGroup`/`Ui.inputGroupActions` are the Zig-view equivalents) |
|
||||
| `input-group` > `textarea` + `input-group-actions` | composite grouped input | the composer shape: ONE bordered field wrapping exactly one `textarea` (first — document order is focus order) plus an optional `input-group-actions` row of controls inside the same border. The group wears the focus ring for its focused descendant and the textarea's own chrome dissolves automatically, so the whole group reads as one field; the textarea keeps its full behavior (`text`, `placeholder`, `on-input`, `on-submit`, `autofocus`, and optional `submit-on-enter`). Group takes `label`, `width`, `height`, `min-width`, `grow`, `key`, `global-key`; the actions row takes `gap` and holds ordinary elements (`if`/`else`/`for` work — swap send for stop while streaming) — put a `<spacer grow="1"/>` between leading and trailing controls (`Ui.inputGroup`/`Ui.inputGroupActions` are the Zig-view equivalents) |
|
||||
|
||||
Not markup-expressible (deliberately — write these as Zig view functions with `canvas.Ui`): `icon_button` (`<button icon="...">` with empty content is the declarative icon button), `data_grid` (per-column cell templates), `popover`/`menu_surface` (anchored to runtime geometry), `segmented_control` (use `tabs`/`toggle-group`: `<button>` children of `<tabs>` lower to segmented triggers automatically, so the active tab lifts per the house treatment). Charts ARE expressible: `<chart>` with `<series values="{binding}">` children binding model f32 iterables — see the Charts section (`.band` series and dynamic series composition stay with `ui.chart`). Built-in vector icons ARE expressible: `<icon name="search"/>` (closed, compile-checked name set; `Ui.icon` is the Zig-view equivalent). App-authored icons: `canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg"))` parses any SVG in the common 24x24 stroke-icon dialect at comptime; register the parsed table once at boot with `canvas.icons.registerAppIcons(&table)` and draw by name via `ui.appIcon(.{...}, "logo")` or `ElementOptions.icon` — registered names render exactly like built-ins on every draw path. Markup `<icon>`/`<button icon>` stay built-in-only (the compiled engine validates names at comptime, where runtime registrations cannot exist — engine parity). Runtime images ARE expressible: `<image image="{cover}" width="120" height="80" label="Cover art"/>` and `<avatar image="{user_image}">CT</avatar>` bind a `u64` ImageId model field/fn (the id is just model data; 0 draws nothing / keeps the initials fallback) — see the Images section; the `image` binding is required on the leaf (an unbound `<image>` is dead markup) and stays avatar+image scoped.
|
||||
|
||||
## Attributes
|
||||
|
||||
Layout: `gap` (flow containers only — stacking containers `stack`/`panel`/`card`/`alert`/`bubble`/`dialog`/`drawer`/`sheet`/`resizable` layer their children, so `gap` there is a validation error, not silence: wrap the children in a `column`/`row` inside; on `split` it sets the divider band thickness), `padding` (uniform), `grow`, `width`, `height` (definite: the element is exactly that size — intrinsic content neither shrinks nor silently overflows it; `resizable` treats `width` as the initial width), `min-width` (a floor WITHOUT `width`'s definite max — the element may grow past it but never shrink below; on split panes it bounds the divider drag), `wrap` (`text` only: `wrap="true"` word-wraps at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are honest single-line — one line whose overflow follows `overflow`), `overflow` (`text` only, a teaching error elsewhere: what a single line does with content that does not fit — `ellipsis`, the default, elides behind a trailing … measured with the same metrics paint uses, right for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column where "1…" beats nothing; there is deliberately no overflow-visible), `text-alignment` (start|center|end — text leaves, status bars, surface titles; controls that own their label placement ignore it), `columns` (`grid` only: fixed column count, omit for the derived near-square grid; a teaching error elsewhere), `main` (start|center|end|space_between), `cross` (stretch|start|center|end), `virtualized`, `virtual-item-extent`, `anchor` (`dropdown-menu` and `tooltip`, literal `below`/`above`: floats the surface against its parent instead of the flow — auto-flips when the preferred side does not fit, height clamps to the chosen side, x clamps into the window; an anchored tooltip's visibility is runtime-owned hover intent, unlike the model-owned dropdown), `anchor-alignment` (with `anchor`: `start`/`end`/`stretch` — stretch also widens the surface to at least the anchor's width, the select-menu look), `anchor-offset` (with `anchor`: literal gap in points, default 4), `tooltip-delay` (`tooltip` only, beside `anchor` — a teaching error elsewhere or without it: hover-intent show delay in ms, default 600, `"0"` = instant; keyboard-focus reveals are always immediate), `overscroll` (`scroll` only, a teaching error elsewhere: `none` pins the region at its content edges — the shipped default via the `ScrollPhysics.overscroll` token — `rubber_band` lets it bounce past them on both the engine and native paths, `default` follows the token).
|
||||
Layout: `gap` (flow containers only — stacking containers `stack`/`panel`/`card`/`alert`/`bubble`/`dialog`/`drawer`/`sheet`/`resizable` layer their children, so `gap` there is a validation error, not silence: wrap the children in a `column`/`row` inside; on `split` it sets the divider band thickness), `padding` (uniform), `grow`, `width`, `height` (definite: the element is exactly that size — intrinsic content neither shrinks nor silently overflows it; `resizable` treats `width` as the initial width), `min-width` (a floor WITHOUT `width`'s definite max — the element may grow past it but never shrink below; on split panes it bounds the divider drag), `max-width` (a ceiling WITHOUT `width`'s definite min — the element still shrinks with a narrow parent; use a growing child inside a centered row for a responsive content column), `wrap` (`text` only: `wrap="true"` word-wraps at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are honest single-line — one line whose overflow follows `overflow`), `overflow` (`text` only, a teaching error elsewhere: what a single line does with content that does not fit — `ellipsis`, the default, elides behind a trailing … measured with the same metrics paint uses, right for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column where "1…" beats nothing; there is deliberately no overflow-visible), `text-alignment` (start|center|end — text leaves, status bars, surface titles; controls that own their label placement ignore it), `columns` (`grid` only: fixed column count, omit for the derived near-square grid; a teaching error elsewhere), `main` (start|center|end|space_between), `cross` (stretch|start|center|end), `virtualized`, `virtual-item-extent`, `anchor` (`dropdown-menu` and `tooltip`, literal `below`/`above`: floats the surface against its parent instead of the flow — auto-flips when the preferred side does not fit, height clamps to the chosen side, x clamps into the window; an anchored tooltip's visibility is runtime-owned hover intent, unlike the model-owned dropdown), `anchor-alignment` (with `anchor`: `start`/`end`/`stretch` — stretch also widens the surface to at least the anchor's width, the select-menu look), `anchor-offset` (with `anchor`: literal gap in points, default 4), `tooltip-delay` (`tooltip` only, beside `anchor` — a teaching error elsewhere or without it: hover-intent show delay in ms, default 600, `"0"` = instant; keyboard-focus reveals are always immediate), `overscroll` (`scroll` only, a teaching error elsewhere: `none` pins the region at its content edges — the shipped default via the `ScrollPhysics.overscroll` token — `rubber_band` lets it bounce past them on both the engine and native paths, `default` follows the token).
|
||||
Appearance/state: `variant` (default|primary|secondary|outline|ghost|destructive), `size` (the control scale default|sm|lg|icon on every sized element; on `text` also the typography rungs heading|display — named typography token steps (`heading_size` 28, `display_size` 48, themable like every token) for section headings and hero stats/timer numerals. The two axes stay apart: heading/display on a control is a teaching error naming text as their home, unknown values list the vocabulary, and numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `value`, `placeholder`, `icon` (`button`, `toggle-button`, `list-item`, `menu-item`: vector icon drawn inline — buttons/toggle-buttons before the label, list/menu items as a leading slot; a teaching error anywhere else. A built-in name, `app:<name>`, or one `{binding}` resolving to such a name). **One size register per row**: every control class shares the control height at a given register (default 36, sm 31.5, lg 40.5 before density), so a toolbar/filter row reads as one height exactly when every control in it carries the SAME `size` — mixing `size="sm"` buttons with a default field renders two heights in one row, and hand-sized pressable panels (`height="30"`) never land on the scale; compose rows from real controls at one register.
|
||||
Focus: `autofocus` (focusable controls only — a teaching error elsewhere): moves keyboard focus to the element when it MOUNTS or when the bound value turns on, edge-triggered so holding it true never re-steals focus from the user. The TEA way to focus an editor on note-create (`<text-field autofocus="{editing}" ...>` or mount the field under an `<if>` with `autofocus="true"`; Zig views use `ElementOptions.autofocus`) and to give keyboard-first apps their first focus without a click.
|
||||
Semantics: `role` (listitem, treeitem, button, ...; `treeitem` also makes the row part of its tree's roving keyboard focus set), `label` (accessible name — it REPLACES the element's text content as the announced name, so snapshot greps and screen readers see the label, never the text; don't `label` an element whose visible text you grep for), `expanded` (tree rows: disclosure state, model-owned — omit on leaves). Accessible names are ENFORCED: an interactive control with no text content, no `text=`, and no `label=` is a validation error (icon-only controls need `label`; text-entry controls need `label` or `placeholder`), unknown/misused literal roles are errors (`role="tree"` on a text leaf can never hold rows), unnamed avatars and labels duplicating the text content are warnings (`label=""` marks an image decorative). Zig-built trees get the same discipline from `canvas.expectA11yAuditSweepClean` (missing names as the bridges would announce them, focusables clipped out of keyboard reach, identically labeled siblings) — adopt it next to the layout sweep.
|
||||
@@ -484,7 +484,7 @@ For `<if test>`, prefer an explicit boolean predicate method over numeric truthi
|
||||
|
||||
## Messages
|
||||
|
||||
`on-press`, `on-double-press`, `on-toggle`, `on-change`, `on-submit` (enter in a text field; primary+enter in a textarea, where enter inserts a newline), `on-dismiss` (dismissible surfaces: dialog, drawer, sheet, dropdown-menu — dispatched when Escape or a click outside dismisses the surface, so the model owns the close), `on-hold` (press-and-hold, see the Pickers section), and `on-hover-enter`/`on-hover-leave` (the pointer-hover containment pair, below) take `tag` or `tag:{payload}`. The tag must be an arm of your `Msg` union; payload bindings coerce to the arm's payload type. In TypeScript, an arm is `{ readonly kind: "tag"; readonly field: Type }`; in Zig it is a tagged-union variant. `on-input` is special: name an arm carrying `TextInputEvent` from `@native-sdk/core/text` in TypeScript or `canvas.TextInputEvent` in Zig. `on-scroll` (the `scroll` element only) similarly carries `ScrollState` from `@native-sdk/core/events` in TypeScript or `canvas.ScrollState` in Zig. In Zig builder views the constructors are `Ui.inputMsg(.tag)` / `Ui.scrollMsg(.tag)` on `on_input` / `on_scroll`. `on-reach-end` (the `scroll` element only; `on_reach_end` in Zig views, any scroll container including the windowed virtual list) is a plain Msg dispatched when a user scroll comes within one viewport of the content end — the infinite-fetch signal, fired once per approach with hysteresis (re-arms past 1.5 viewports, which appending a batch causes by growing the extent). A programmatic jump to the end fires once and NEVER re-arms while the offset stays near the end — re-arming needs a post-scroll observation at least 1.5 viewports from it.
|
||||
`on-press`, `on-double-press`, `on-toggle`, `on-change`, `on-submit` (Enter in a text field; primary+Enter in a default textarea; plain Enter too when that textarea declares `submit-on-enter="true"`), `on-dismiss` (dismissible surfaces: dialog, drawer, sheet, dropdown-menu — dispatched when Escape or a click outside dismisses the surface, so the model owns the close), `on-hold` (press-and-hold, see the Pickers section), and `on-hover-enter`/`on-hover-leave` (the pointer-hover containment pair, below) take `tag` or `tag:{payload}`. The tag must be an arm of your `Msg` union; payload bindings coerce to the arm's payload type. In TypeScript, an arm is `{ readonly kind: "tag"; readonly field: Type }`; in Zig it is a tagged-union variant. `on-input` is special: name an arm carrying `TextInputEvent` from `@native-sdk/core/text` in TypeScript or `canvas.TextInputEvent` in Zig. `on-scroll` (the `scroll` element only) similarly carries `ScrollState` from `@native-sdk/core/events` in TypeScript or `canvas.ScrollState` in Zig. In Zig builder views the constructors are `Ui.inputMsg(.tag)` / `Ui.scrollMsg(.tag)` on `on_input` / `on_scroll`. `on-reach-end` (the `scroll` element only; `on_reach_end` in Zig views, any scroll container including the windowed virtual list) is a plain Msg dispatched when a user scroll comes within one viewport of the content end — the infinite-fetch signal, fired once per approach with hysteresis (re-arms past 1.5 viewports, which appending a batch causes by growing the extent). A programmatic jump to the end fires once and NEVER re-arms while the offset stays near the end — re-arming needs a post-scroll observation at least 1.5 viewports from it.
|
||||
|
||||
Scroll offsets follow the same mirror discipline as text: the Msg carries the offset the runtime ALREADY applied, so store it in the model and echo it back through the scroll's `value` — the echoed source value equals the runtime offset, which the scroll reconcile rule treats as "unchanged", so rebuilds never stomp live scrolling. `on-scroll` is how long content pages or lazy-loads: keep a bounded window in the model and slide it from `offset` (near-end when `offset + viewport_extent` approaches `content_extent`).
|
||||
|
||||
@@ -814,8 +814,8 @@ The `.wake` platform event is how live platforms marshal worker completions onto
|
||||
|
||||
> **Effects test surface — exact signatures.** These are the complete fake-executor entry points; do not excavate the SDK source. Harness and switch: `const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(w, h) });` then `app_state.effects.executor = .fake;` before dispatching. Every `feed*`/`fire*` returns `error{EffectNotFound}!void`; every `pending*At(index: usize)` returns an optional request record; each family's `pending*Count()` returns `usize`.
|
||||
>
|
||||
> - Spawn: `pendingSpawnAt(index: usize) ?SpawnRequest` · `feedLine(key: u64, bytes: []const u8)` · `feedStderr(key: u64, bytes: []const u8)` · `feedExit(key: u64, code: i32)` · `feedExitReason(key: u64, code: i32, reason: EffectExitReason)` (`feedExit` is the clean-exit sugar; the reason enum covers `signaled`/`cancelled`/...) · `feedOutput(key: u64, bytes: []const u8)` (raw collect-stdout append, no line framing).
|
||||
> - Fetch: `pendingFetchAt(index: usize) ?FetchRequest` · `feedResponse(key: u64, status: u16, body: []const u8)` · `feedResponseOutcome(key: u64, outcome: EffectFetchOutcome, status: u16, body: []const u8)`.
|
||||
> - Spawn: `pendingSpawnAt(index: usize) ?SpawnRequest` · `feedLine(key: u64, bytes: []const u8)` · `feedLineWithMetadata(key: u64, bytes: []const u8, truncated: bool, dropped_before: u32)` (line mode only; also serves streamed fetches) · `feedStderr(key: u64, bytes: []const u8)` · `feedExit(key: u64, code: i32)` · `feedExitReason(key: u64, code: i32, reason: EffectExitReason)` (`feedExit` is the clean-exit sugar; the reason enum covers `signaled`/`cancelled`/...) · `feedOutput(key: u64, bytes: []const u8)` (raw collect-stdout append, no line framing).
|
||||
> - Fetch: `pendingFetchAt(index: usize) ?FetchRequest` · `feedResponse(key: u64, status: u16, body: []const u8)` · `feedResponseOutcome(key: u64, outcome: EffectFetchOutcome, status: u16, body: []const u8)` · `feedResponseOutcomeWithMetadata(key: u64, outcome: EffectFetchOutcome, status: u16, body: []const u8, truncated: bool, dropped_before: u32)`.
|
||||
> - Files: `pendingFileAt(index: usize) ?FileRequest` · `feedFileResult(key: u64, outcome: EffectFileOutcome, bytes: []const u8)`.
|
||||
> - Clipboard: `pendingClipboardAt(index: usize) ?ClipboardRequest` · `feedClipboardResult(key: u64, outcome: EffectClipboardOutcome, text: []const u8)`.
|
||||
> - Host commands: `pendingHostAt(index: usize) ?HostRequest` · `feedHostResult(key: u64, ok: bool, bytes: []const u8)`.
|
||||
|
||||
@@ -98,7 +98,7 @@ Logic:
|
||||
- **Exceptions — `throw`/`try`/`catch`/`finally` as pure control flow.** Inside a core, exceptions are deterministic: `throw` carries a subset VALUE and unwinds to the nearest enclosing `catch` — across helper calls, out of array-method callbacks (a `throw` inside `.map`'s callback exits the whole loop, like JS), through nested `try`s, with `finally` running on every path (fall-through, `return`, `break`/`continue`, and throw alike). The discipline is two rules. First (NS1057): thrown values are kind-tagged subset shapes — throw kind-discriminated records (`throw { kind: "bad_digit", at: i } as ParseError`, where `ParseError` is an interface with a string-literal `kind` field or a `kind`-discriminated union; a single-shape core may also throw a number), and SEVERAL distinct shapes may throw: the checker collects every shape the core throws into its implicit thrown union. The catch binding IS that union — narrow it in place with kind tests, no `as` ceremony: `catch (e) { if (e.kind === "bad_digit") return -e.at; if (e.kind === "io") return e.code; return -1; }` (or `switch (e.kind)` — tsc cannot prove exhaustiveness over the implicit union, so give the switch a `default` or a trailing return). Bare rethrow (`throw e;`) re-raises the bound value — a narrowed arm included — and `catch { ... }` needs no binding; the single-`as` form (`const err = e as ParseError;`) stays legal in single-shape cores (and for a DECLARED union whose arms equal the thrown set — declare `type AppError = ... | ...` and `as AppError` works). What teaches: untagged values in a heterogeneous set, two shapes sharing one `kind` with different payloads, asserting one member shape of a multi-shape core, the binding escaping untyped into a call/store/return, and `throw new Error(...)` (engine error objects carry stack traces with no native layout). Second (NS1058): `finally` never redirects control flow — no `return`/`throw`/`break`-out inside it (JS's own no-unsafe-finally rule; loops fully inside the finally may break within themselves). An UNCAUGHT throw that reaches an exported function's boundary is a defined deterministic panic — exactly where node's process would crash. A throw mid-mutation of an owned array keeps the mutations applied so far, exactly like JS — the catch sees the array as node would.
|
||||
- **Local function values — const helpers hoist.** `const scale = (x: number): number => x * 3;` (arrow or `function` expression) hoists to an ordinary module-level fn when it is capture-free (module constants and other const helpers are fine to reference; enclosing locals/params are not — pass them as parameters), fully annotated (every parameter and the return type), and used only by direct calls (`scale(v)`, recursion included) or as an array-method callback (`xs.map(scale)`, comparators included). Everything else teaches NS1054: captures, missing annotations, `let` bindings, returning/storing the value, passing it to your own functions, calling through a record field. Capturing a locally-owned array also ENDS its ownership at the capture (a later mutation is the NS1051 teach) — the stored closure would retain the reference.
|
||||
|
||||
Not yet in v1 — genuine roadmap deferrals, each stopping with a loud, tailored NS9001 naming the rewrite (never missing basic syntax; the banned-with-a-rule families live in "What the subset means" above): `.toSorted()`/`.sort()` without a comparator (JS ToString ordering; pass `(a, b) => a - b`), `.reduce` without an initial value (a taught NS1007 — JS throws on an empty array) or with an index parameter (use a classic loop), `.indexOf`/`.includes` on record arrays (match a field with `.find`/`.findIndex`), `.join` on number arrays (elements are float-valued; join byte values instead), float values (`/`, `**`, `Math.round`, `Math.sqrt`, float `Math.floor`-family results) where an integer is required such as an index (a taught NS1016 — those values can be fractional or NaN), Math methods beyond the batch above, `Number` methods beyond the three classifiers, float-valued template holes (JS float-to-string fidelity is a runtime v2 surface), arrays of unions (`readonly View[]`) or arrays of byte-strings (`readonly Uint8Array[]`) as model fields (wrap the element in a single-field interface), record payloads on `Cmd.request` results (results and errors arrive as one bytes payload; the record-shaped results are `Cmd.fetch`'s `{ status, body }` arm, `Cmd.spawn`'s collect `{ code, output }` arm, and the fixed audio event arm), streaming fetch responses (`Cmd.fetch` is buffered only; spawn line streams are the streaming surface), a collect spawn's stderr tail (v1 delivers the exit code and stdout; stderr is not surfaced — put diagnostics on stdout or check the code), per-line truncation flags (a stdout line over the engine's 4 KiB line bound arrives cut, without a flag), and non-timer subscriptions (`Sub.timer` is the one subscription; one-shot needs are `Cmd.delay`, and process/audio streams are Cmd-initiated, not subscribed).
|
||||
Not yet in v1 — genuine roadmap deferrals, each stopping with a loud, tailored NS9001 naming the rewrite (never missing basic syntax; the banned-with-a-rule families live in "What the subset means" above): `.toSorted()`/`.sort()` without a comparator (JS ToString ordering; pass `(a, b) => a - b`), `.reduce` without an initial value (a taught NS1007 — JS throws on an empty array) or with an index parameter (use a classic loop), `.indexOf`/`.includes` on record arrays (match a field with `.find`/`.findIndex`), `.join` on number arrays (elements are float-valued; join byte values instead), float values (`/`, `**`, `Math.round`, `Math.sqrt`, float `Math.floor`-family results) where an integer is required such as an index (a taught NS1016 — those values can be fractional or NaN), Math methods beyond the batch above, `Number` methods beyond the three classifiers, float-valued template holes (JS float-to-string fidelity is a runtime v2 surface), arrays of unions (`readonly View[]`) or arrays of byte-strings (`readonly Uint8Array[]`) as model fields (wrap the element in a single-field interface), record payloads on `Cmd.request` results (results and errors arrive as one bytes payload; the record-shaped results are `Cmd.fetch`'s buffered `{ status, body }` arm, `Cmd.spawn`'s collect `{ code, output }` arm, and the fixed audio event arm), a collect spawn's stderr tail (v1 delivers the exit code and stdout; stderr is not surfaced — put diagnostics on stdout or check the code), per-line truncation flags (a stdout or response line over its configured bound arrives cut, without a flag), and non-timer subscriptions (`Sub.timer` is the one subscription; one-shot needs are `Cmd.delay`, and process/fetch/audio streams are Cmd-initiated, not subscribed).
|
||||
|
||||
## Effects are Cmd data
|
||||
|
||||
@@ -128,7 +128,7 @@ The command set (Cmd wire format v3):
|
||||
- `Cmd.now("tick")` — request a timestamp; the runtime dispatches the named Msg arm with the time (ms) as its payload. The target arm must carry exactly one number field (`{ kind: "tick", at: number }`), and tsc checks that for you.
|
||||
- `Cmd.host(name, ...args)` — a fire-and-forget host command by literal name; the host decides what the name means. Args are numbers, OR exactly one bytes payload: a `Uint8Array` (`Cmd.host("clipboard.write", model.draft)`) or a flat inline record of number/boolean/`Uint8Array` fields (`Cmd.host("cfg.save", { gain: model.gain, on: model.muted, label: asciiBytes("main") })`) — the record lowers to one bytes payload from your types at build time, byte-identical under node and native. Anything else (a smuggled string, a nested record, a payload plus extra args) is a taught error (NS1020/NS1026).
|
||||
- `Cmd.request(name, payload, { key?, ok, err })` — a routed host command: the host performs `name` with the payload (same bytes/record rules) and dispatches exactly one result back to you as an ordinary Msg — the `ok` arm with the result bytes on success, or the `err` arm with the error bytes on failure. Both arms must carry exactly one `Uint8Array` field (`{ kind: "loaded", body: Uint8Array }`), checked by tsc and taught by NS1027. The routing is data — string-literal arm names, never callbacks — so the result decoder derives from your Msg types at build time. The optional `key` (a string literal) names the in-flight effect: issuing a request whose key is already in flight replaces it (the old result is dropped), which is the debounce/exactly-one-in-flight discipline.
|
||||
- `Cmd.cancel(key)` — drop the in-flight keyed effect with that key, silently: a cancelled request, named engine op (`readFile`/`writeFile`/`fetch`/`clipboardRead`), or armed delay dispatches NEITHER arm — its result is simply dropped. The one exception is a live spawn stream (below): cancel ends the child and the stream's `err` arm dispatches with `cancelled` — killing a process is an observable event, kept loud on purpose.
|
||||
- `Cmd.cancel(key)` — drop the in-flight keyed effect with that key, silently: a cancelled request, buffered named engine op (`readFile`/`writeFile`/`fetch`/`clipboardRead`), or armed delay dispatches NEITHER arm — its result is simply dropped. Live spawn and streaming-fetch operations are the exceptions: cancel ends the stream and its `err` arm dispatches with `cancelled`, loud because an app may already have handled earlier stream messages.
|
||||
- `Cmd.batch([a, b])` — several commands from one dispatch, performed in order.
|
||||
|
||||
### The named engine ops
|
||||
@@ -137,7 +137,7 @@ These map directly onto the host's effect engine — files, HTTP, the clipboard,
|
||||
|
||||
- `Cmd.readFile(path, { key?, ok, err })` — read a whole file. `ok` arm: one `Uint8Array` field with the content. `err` reasons: `not_found`, `io_failed`, `truncated` (the file exceeds the engine's 1 MiB read bound — a cut file never passes as whole), `rejected`. Paths are at most 1024 bytes.
|
||||
- `Cmd.writeFile(path, bytes, { key?, ok, err })` — write a whole file (parent directories created, an existing file replaced whole; at most 1 MiB). `ok` arm: NO payload fields (`{ kind: "wrote" }`) — a successful write has nothing to report. `err` reasons: `io_failed`, `rejected`.
|
||||
- `Cmd.fetch({ url, method?, headers?, body?, timeoutMs? }, { key?, ok, err })` — a buffered HTTP(S) exchange. `ok` arm: exactly two fields, one `number` and one `Uint8Array` (`{ kind: "fetched", status: number, body: Uint8Array }`) — matched by type, so the names are yours. The status is the real HTTP status: a 404 is still `ok` (an HTTP-level error is a delivered response). `err` reasons: `connect_failed`, `tls_failed`, `protocol_failed`, `timed_out`, `rejected`, and `truncated` (the body exceeded the engine's 256 KiB buffered bound — never delivered silently cut). The spec is an inline object: `url` bytes (≤ 2 KiB), `method` one of `"GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"` (default GET), `headers` an inline flat record — names are compile-time ASCII, values are string literals OR runtime bytes (`{ authorization: bearerToken(model.apiKey), "content-type": "application/json" }` — how a launch-supplied key rides an `Authorization` header; ≤ 8 headers, ≤ 1 KiB total, NS1029/NS1030), `body` bytes (≤ 64 KiB), `timeoutMs` a positive integer literal (engine default when omitted). Buffered only — no streaming responses in v1.
|
||||
- `Cmd.fetch({ url, method?, headers?, body?, timeoutMs? }, { key?, ok, err })` — a buffered HTTP(S) exchange. `ok` arm: exactly two fields, one `number` and one `Uint8Array` (`{ kind: "fetched", status: number, body: Uint8Array }`) — matched by type, so the names are yours. The status is the real HTTP status: a 404 is still `ok` (an HTTP-level error is a delivered response). `err` reasons: `connect_failed`, `tls_failed`, `protocol_failed`, `timed_out`, `rejected`, and `truncated` (the body exceeded the engine's 256 KiB buffered bound — never delivered silently cut). The spec is an inline object: `url` bytes (≤ 2 KiB), `method` one of `"GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"` (default GET), `headers` an inline flat record — names are compile-time ASCII, values are string literals OR runtime bytes (`{ authorization: bearerToken(model.apiKey), "content-type": "application/json" }` — how a launch-supplied key rides an `Authorization` header; ≤ 8 headers, ≤ 1 KiB total, NS1029/NS1030), `body` bytes (≤ 64 KiB), `timeoutMs` a positive integer literal (engine default when omitted).
|
||||
- `Cmd.clipboardWrite(bytes)` — put bytes on the system clipboard, fire-and-forget: there is no routing, and a refused or over-bound write is dropped by design.
|
||||
- `Cmd.clipboardRead({ key?, ok, err })` — read the clipboard. `ok` arm: one `Uint8Array` field with the text. `err` reasons: `failed` (no clipboard service, over-bound content, pasteboard error), `rejected`.
|
||||
- `Cmd.showNotification({ title, subtitle?, body? })` — request a desktop notification, fire-and-forget: `title` is required bytes (1–128), `subtitle` optional bytes (up to 128), and `body` optional bytes (up to 1024). The host validates all fields before entering the platform service; invalid or unavailable requests fail closed, and Focus / Do Not Disturb and user settings remain authoritative after acceptance. Fake execution and session replay never display one.
|
||||
@@ -145,13 +145,14 @@ These map directly onto the host's effect engine — files, HTTP, the clipboard,
|
||||
|
||||
One honesty note on `Cmd.persist()`: it compiles and encodes, but no shipping host implements the persist verb yet, so the checker teaches NS1028 as a WARNING (never failing the build). Persist real state with `Cmd.writeFile` and load it back with `Cmd.readFile` from `initialModel`'s boot command — the pattern every real app uses.
|
||||
|
||||
### The streaming ops: spawn, audio, and channels
|
||||
### The streaming ops: fetch, spawn, audio, and channels
|
||||
|
||||
Three effect families deliver MANY results from one command — a keyed stream the app opens imperatively and drives (this is the opposite of `Sub`: a Sub is declared from the model and the host reconciles it; a stream is a `Cmd` with a lifecycle you cancel or stop). Routing still follows the `Cmd.request` rules: string-literal arm names, shapes checked by tsc and taught by NS1027.
|
||||
Four effect families deliver MANY results from one command — a keyed stream the app opens imperatively and drives (this is the opposite of `Sub`: a Sub is declared from the model and the host reconciles it; a stream is a `Cmd` with a lifecycle you cancel or stop). Routing still follows the `Cmd.request` rules: string-literal arm names, shapes checked by tsc and taught by NS1027.
|
||||
|
||||
- `Cmd.fetch({ url, method?, headers?, body?, timeoutMs?, maxLineBytes? }, { key?, line, ok, err })` — line-stream an HTTP(S) response. Each complete response line dispatches `line` as one `Uint8Array` field, so SSE and NDJSON parsers can update the Model incrementally. Exactly ONE terminal follows: a delivered, lossless response (non-2xx included) dispatches `ok` as one number field carrying the HTTP status; a cut/dropped line dispatches `err: truncated`, and transport failure, timeout, rejection, or cancellation dispatches `err` with that reason. `maxLineBytes` raises the 4 KiB per-line default up to 256 KiB for large event records; the per-line truncation flag is not exposed directly because any such loss makes the terminal loud. A duplicate live key is rejected rather than replaced so two responses cannot splice into one logical stream; `Cmd.cancel(key)` ends it loudly through `err: cancelled`.
|
||||
- `Cmd.spawn(argv, { key?, stdin?, line?, exit, err })` — run a subprocess, streaming stdout line by line. `argv` is an inline array literal of bytes elements (`[asciiBytes("/bin/ps"), asciiBytes("-axo")]`, at most 16 elements, 2 KiB total; the array shape is NS1029, the bounds NS1030), and `stdin` (optional bytes, ≤ 4 KiB) is written to the child once. Each stdout line dispatches the `line` arm (one `Uint8Array` field) as it arrives, across dispatches; omit `line` to drop lines (an exit-only spawn, e.g. piping stdin to `pbcopy`). Exactly ONE terminal ends the stream: a clean exit dispatches the `exit` arm — one number field carrying the exit code (a non-zero code is still `exit`: the process ran; its failure code is yours to read) — and every other end dispatches `err` with the reason bytes: `signaled`, `cancelled`, `rejected` (a duplicate live key, or dynamic argv/stdin the engine refused), `spawn_failed` (the binary could not start). Lines over the engine's 4 KiB line bound arrive cut.
|
||||
- `Cmd.spawn(argv, { key?, stdin?, collect: true, exit, err })` — the same child, whole stdout buffered instead of streamed (the system-monitor shape: run `ps`, parse the block). No `line` arm (NS1027 teaches the conflict). The `exit` arm is a two-field record — one number field (the exit code) and one `Uint8Array` field (the collected stdout, up to 512 KiB), matched by type like `Cmd.fetch`'s arm. Collected stdout over the bound routes `err` with `truncated` — a cut block never parses as whole.
|
||||
- `Cmd.cancel(key)` aimed at a live spawn ends the child mid-stream; the stream's `err` arm dispatches with `cancelled` — loud on purpose, because killing a process is an observable event (the contrast with the named ops, whose cancel is silent). Spawn keys are the ONE exception to the replace rule: a spawn whose key is already streaming is rejected (`err` gets `rejected`), never replaced — a running subprocess is never killed implicitly; cancel it first.
|
||||
- `Cmd.cancel(key)` aimed at a live spawn ends the child mid-stream; the stream's `err` arm dispatches with `cancelled` — loud on purpose, because killing a process is an observable event (the contrast with buffered named ops, whose cancel is silent). Spawn keys share streaming fetch's duplicate discipline: a spawn whose key is already streaming is rejected (`err` gets `rejected`), never replaced — a running subprocess is never killed implicitly; cancel it first.
|
||||
- `Cmd.audioPlay(key, { path?, url?, cachePath?, expectedBytes? }, { event })` — open the audio event stream. One player is the whole surface, so a new `audioPlay` always REPLACES the current playback (the one key-reuse exception besides `Cmd.request`). The source cascade is the engine's: the local `path` is tried first, a missing file falls through to `url` (streamed progressively, cached at `cachePath` when given, integrity-gated by `expectedBytes` — omitted/0 means unknown size). At least one of `path`/`url` is required (NS1029); each is bytes, at most 1 KiB (NS1030). Prefer OMITTING `cachePath` for URL sources: when the app wiring configures a caches directory (`TsUiApp`'s `audio_cache_dir`), the host derives the conventional content-addressed cache path from the URL itself — your update never builds filesystem paths, and replay re-derives the same path by construction. Pass `cachePath` only to override that convention.
|
||||
- The `event` arm is the one SDK-fixed record shape, six fields matched by NAME: `state` (the `AudioState` string-literal union — import it from `@native-sdk/core/events`, or declare an alias with exactly the members `"loaded" | "position" | "completed" | "failed" | "rejected" | "spectrum"` in any order; the runtime matches members by name), `positionMs: number`, `durationMs: number` (milliseconds; the duration is the player's estimate), `playing: boolean`, `buffering: boolean` (true while a streamed url is stalled waiting for bytes), and `bands: Uint8Array` (the 32 spectrum band magnitudes, 0–255 each, all zeros outside `"spectrum"` events). Every playback event dispatches this arm — `"failed"` (unplayable source, decode/device failure) and `"rejected"` (an empty or over-long source) included, so failure is never silence — until `Cmd.audioStop` closes the stream. `"completed"` fires once at the natural end and does NOT close the stream: starting the next track from it is the idiom.
|
||||
- `Cmd.audioPause(key)` / `Cmd.audioResume(key)` / `Cmd.audioStop(key)` / `Cmd.audioSeek(key, ms)` / `Cmd.audioSetVolume(key, volume)` — fire-and-forget control verbs: no result of their own; their consequences arrive on the event stream (`audioResume` on a dead player reports one `"failed"` event, never silence). A verb whose key names no open stream is a no-op. `audioStop` is the audio stream's close — no events for the key after it (`Cmd.cancel` does not apply to audio). Volume is clamped 0..1 and remembered across tracks; a literal outside 0..1 (or a negative seek literal) stops the build (NS1030).
|
||||
@@ -207,7 +208,7 @@ export function subscriptions(model: Model): Sub<Msg> {
|
||||
|
||||
Sub values follow the Cmd purity rule with their own home (NS1025): built inline in `subscriptions`' return path, never stored, never returned from anywhere else. Debounced re-arm falls out of reconciliation — change the key or interval and the timer re-arms; drop it from the set and it stops.
|
||||
|
||||
Keep the Sub-vs-stream line straight: a Sub is DECLARATIVE — derived from the model, started and stopped by reconciliation, and the app never opens or closes one. The multi-result streams (`Cmd.spawn`'s lines, `Cmd.audioPlay`'s events, and audio capture chunks) are Cmd-INITIATED — imperative opens with a keyed lifecycle the app drives (`Cmd.cancel` for spawn, `Cmd.audioStop` for playback, `Cmd.audioCaptureStop` for capture). If the effect should exist exactly while some model state holds, it wants a Sub shape; if the app decides when it starts and ends, it is a stream.
|
||||
Keep the Sub-vs-stream line straight: a Sub is DECLARATIVE — derived from the model, started and stopped by reconciliation, and the app never opens or closes one. The multi-result streams (`Cmd.fetch`'s response lines, `Cmd.spawn`'s stdout lines, `Cmd.audioPlay`'s events, `Cmd.channelOpen`'s posts, and audio capture chunks) are Cmd-INITIATED — imperative opens with a keyed lifecycle the app drives (`Cmd.cancel` for fetch/spawn, `Cmd.audioStop` for playback, `Cmd.channelClose` for external channels, and `Cmd.audioCaptureStop` for capture). If the effect should exist exactly while some model state holds, it wants a Sub shape; if the app decides when it starts and ends, it is a stream.
|
||||
|
||||
One caveat for node-side pokes: the build resolves the `@native-sdk/core*` specifiers for you, but plain `node` does not know them, so quick behavioral checks under node work directly on cores with no SDK import, and on cores importing `Cmd`, `Sub`, `asciiBytes`, or the text engine only with a module mapping (or by copying the SDK module files next to the core and rewriting the specifiers). `native dev --core` already maps them.
|
||||
|
||||
@@ -222,7 +223,7 @@ One caveat for node-side pokes: the build resolves the `@native-sdk/core*` speci
|
||||
- **The entry contract (NS1014)**: `update`, `initialModel`, `subscriptions`, the wiring channels (`commandMsg`/`keyMsg`/`frameMsg`/`pinchMsg`/`dropMsg`/`appearanceMsg`/`chromeMsg`/`envMsgs`), and `viewUnbound` are DECLARED in `core.ts` and exported under their own names (`export` on the declaration or an un-renamed `export { update }` list entry — a rename or a re-export from an imported module cannot bind an entry point) - imports may FEED them, never replace them. The markup binding surface is also entry-only: an exported single-Model-parameter helper binds (`{doneCount}`) only when it is DECLARED in `core.ts` — export lists participate under their exported names (`export { taskTotal as taskCount }` binds `{taskCount}`), but a re-export of an imported helper does not bind (under node the app's module object is the entry's exports, so it would bind natively but not exist under node). Imported modules export cross-module API for update and the entry helpers to call.
|
||||
- **SDK library modules**: `@native-sdk/core/text` ships the byte-splice text engine - `applyTextInputEvent(state, event, capacity)` / `clampedInsertEvent` over `TextEditState` (the full caret/word/selection/IME reducer for markup text controls), plus `containsIgnoreCase`, `orderIgnoreCase`, and `trimAsciiSpaces`. `@native-sdk/core/events` ships the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchEvent`, `FileDropEvent`, `ColorScheme`/`AppearanceEvent`, `ChromeInsets`/`ChromeButtons`/`ChromeEvent`, `AudioState`/`AudioEvent`, `AudioCaptureState`/`AudioCaptureSource`/`AudioCaptureEvent`) so no core re-types the vocabulary. Unlike `@native-sdk/core` (intrinsic, never compiled into the core) these are ordinary subset TypeScript, compiled INTO your core when imported and absent when not. Under node they resolve like the core module itself. One namespace rule to know (NS1038): module-scope names are unique across the whole import graph, so a core that imports an SDK event type deletes its own in-file mirror of that name.
|
||||
|
||||
The reference splits are `examples/soundboard-ts` (core.ts + library.ts + player.ts + the SDK text engine), `examples/system-monitor-ts` (core.ts + parsers.ts + table.ts + the SDK text engine), and `examples/ai-chat-ts` (core.ts + api.ts — the JSON-over-bytes wire-format reference: request encoding and a targeted parse walk that returns `null` on anything malformed) in the SDK repo.
|
||||
The reference splits are `examples/soundboard-ts` (core.ts + library.ts + player.ts + the SDK text engine), `examples/system-monitor-ts` (core.ts + parsers.ts + table.ts + the SDK text engine), and `examples/chatbot` (core.ts + api.ts — the JSON-over-bytes wire-format reference: request encoding and a targeted parse walk that returns `null` on anything malformed) in the SDK repo.
|
||||
|
||||
## Text is bytes
|
||||
|
||||
@@ -266,7 +267,7 @@ The stays-out tail teaches by name (NS1060 unless noted): `charCodeAt`/`charAt`/
|
||||
|
||||
The SDK text helpers remain for what the methods do not cover: `containsIgnoreCase` (ASCII case-insensitive search) and `orderIgnoreCase` (an ASCII case-insensitive `.toSorted` comparator) from `@native-sdk/core/text`. `trimAsciiSpaces` also stays, but it is NOT `.trim()`: it strips space/tab/CR only — never LF — as a no-copy view, which is exactly right for line-oriented parsers where `\n` is the record separator; for user input and general whitespace, `.trim()` is now the canonical form.
|
||||
|
||||
A freshly created `Uint8Array` is writable until it escapes (stored, returned, passed on); after that it is immutable like everything else. One consequence worth knowing before you build byte output: a buffer PASSED to a helper has escaped, so a writing helper cannot take an out-parameter — build bytes with measure-then-fill inside one function (count the output length, allocate exactly it, write inline), or build a `Uint8Array[]` push-builder of parts and concatenate once (`examples/ai-chat-ts/src/api.ts` does both, JSON escaping included). `===` on two `Uint8Array` values is banned (it would be JS reference identity, which has no native mapping) — compare an id field, or contents with a loop (`s.startsWith(t) && s.length === t.length` is the whole-equality idiom). `bytes.join("-")` renders byte values as decimal text with a literal separator (arena bytes, same result under node); number arrays have no `.join` because their elements are float-valued.
|
||||
A freshly created `Uint8Array` is writable until it escapes (stored, returned, passed on); after that it is immutable like everything else. One consequence worth knowing before you build byte output: a buffer PASSED to a helper has escaped, so a writing helper cannot take an out-parameter — build bytes with measure-then-fill inside one function (count the output length, allocate exactly it, write inline), or build a `Uint8Array[]` push-builder of parts and concatenate once (`examples/chatbot/src/api.ts` does both, JSON escaping included). `===` on two `Uint8Array` values is banned (it would be JS reference identity, which has no native mapping) — compare an id field, or contents with a loop (`s.startsWith(t) && s.length === t.length` is the whole-equality idiom). `bytes.join("-")` renders byte values as decimal text with a literal separator (arena bytes, same result under node); number arrays have no `.join` because their elements are float-valued.
|
||||
|
||||
## Text input from markup
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ fn allTextSpansHaveColor(widget: canvas.Widget, color: canvas.TextSpanColor) boo
|
||||
fn expectCodeTabInsertion(source: []const u8, expected: []const u8) !void {
|
||||
const edit = canvas.widgetCodeTabTextEditEvent(.{
|
||||
.kind = .textarea,
|
||||
.code_editor = true,
|
||||
.runtime_flags = .{ .code_editor = true },
|
||||
.text = source,
|
||||
}, .{
|
||||
.phase = .key_down,
|
||||
@@ -958,7 +958,7 @@ test "line number gutter reserves at least three marker columns" {
|
||||
const tokens = canvas.DesignTokens{};
|
||||
var numbered = canvas.Widget{
|
||||
.kind = .textarea,
|
||||
.code_editor = true,
|
||||
.runtime_flags = .{ .code_editor = true },
|
||||
.code_line_number_digits = 1,
|
||||
};
|
||||
const one_digit_width = widget_metrics.widgetCodeLineNumberGutterWidth(numbered, tokens);
|
||||
@@ -1259,7 +1259,7 @@ test "numbered editable code does not invent trailing horizontal overflow" {
|
||||
const tokens = canvas.DesignTokens{};
|
||||
var editor = canvas.Widget{
|
||||
.kind = .textarea,
|
||||
.code_editor = true,
|
||||
.runtime_flags = .{ .code_editor = true },
|
||||
.code_line_number_digits = 2,
|
||||
.text_no_wrap = true,
|
||||
.text = source,
|
||||
@@ -1301,7 +1301,7 @@ test "editable code caches one batched longest-line measurement across geometry
|
||||
const tokens = canvas.DesignTokens{ .text_measure = &provider };
|
||||
var editor = canvas.Widget{
|
||||
.kind = .textarea,
|
||||
.code_editor = true,
|
||||
.runtime_flags = .{ .code_editor = true },
|
||||
.text_no_wrap = true,
|
||||
.text = source.items,
|
||||
.frame = geometry.RectF.init(0, 0, 240, 80),
|
||||
@@ -1445,7 +1445,7 @@ test "editable code Tab infers the file indentation and falls back to two spaces
|
||||
}) == null);
|
||||
try testing.expect(canvas.widgetCodeTabTextEditEvent(.{
|
||||
.kind = .textarea,
|
||||
.code_editor = true,
|
||||
.runtime_flags = .{ .code_editor = true },
|
||||
.text = "code",
|
||||
}, .{
|
||||
.phase = .key_down,
|
||||
@@ -1454,7 +1454,7 @@ test "editable code Tab infers the file indentation and falls back to two spaces
|
||||
}) == null);
|
||||
try testing.expect(canvas.widgetCodeTabTextEditEvent(.{
|
||||
.kind = .textarea,
|
||||
.code_editor = true,
|
||||
.runtime_flags = .{ .code_editor = true },
|
||||
.text = "code",
|
||||
}, .{
|
||||
.phase = .key_down,
|
||||
@@ -1642,7 +1642,7 @@ test "direct tree code emission honors scroll viewports and later layout pages"
|
||||
.id = 1,
|
||||
.kind = .scroll_view,
|
||||
.frame = geometry.RectF.init(0, 0, 320, 80),
|
||||
.native_scroll = true,
|
||||
.runtime_flags = .{ .native_scroll = true },
|
||||
.children = &horizontal_children,
|
||||
};
|
||||
var horizontal_commands: [64]canvas.CanvasCommand = undefined;
|
||||
@@ -1669,7 +1669,7 @@ test "direct tree code emission honors scroll viewports and later layout pages"
|
||||
.id = 3,
|
||||
.kind = .scroll_view,
|
||||
.frame = geometry.RectF.init(0, 0, 100, 100),
|
||||
.native_scroll = true,
|
||||
.runtime_flags = .{ .native_scroll = true },
|
||||
.children = &vertical_children,
|
||||
};
|
||||
var vertical_commands: [64]canvas.CanvasCommand = undefined;
|
||||
@@ -1709,7 +1709,7 @@ test "editable code highlights the visible tail of a ten-thousand-line source" {
|
||||
.height = 100,
|
||||
}, source.items));
|
||||
var editor = findByText(view.root, source.items).?;
|
||||
try testing.expect(editor.code_editor);
|
||||
try testing.expect(editor.runtime_flags.code_editor);
|
||||
try testing.expectEqual(code_model.Language.css, editor.code_language);
|
||||
try testing.expectEqual(@as(usize, 1), editor.spans.len);
|
||||
try testing.expectEqual(@as(u8, 5), editor.code_line_number_digits);
|
||||
|
||||
@@ -118,19 +118,20 @@ pub const WidgetKeyboardEvent = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Enter in a multi-line editor EDITS instead of submitting: a textarea
|
||||
/// maps a plain Enter keydown to a newline insert, and Shift+Enter stays
|
||||
/// a newline too so single-line muscle memory never destroys text. The
|
||||
/// primary-modifier chord (cmd/ctrl+Enter) is deliberately excluded —
|
||||
/// that is the textarea's submit chord — as is alt+Enter, left free for
|
||||
/// app shortcuts. Single-line kinds return null here and keep
|
||||
/// enter-to-submit. Shared by the runtime edit path and the app `on_input`
|
||||
/// dispatch so the retained text and the model always hear the same edit.
|
||||
pub fn widgetKeyboardNewlineTextEditEvent(kind: WidgetKind, event: WidgetKeyboardEvent) ?TextInputEvent {
|
||||
if (kind != .textarea) return null;
|
||||
/// Enter in a multi-line editor normally EDITS instead of submitting.
|
||||
/// A textarea with `submit_on_enter` reverses only the plain gesture:
|
||||
/// Enter is left for its submit handler while Shift+Enter stays a
|
||||
/// newline. The primary-modifier chord (cmd/ctrl+Enter) is deliberately
|
||||
/// excluded — it is always a textarea submit chord — as is alt+Enter,
|
||||
/// left free for app shortcuts. Single-line kinds return null here and
|
||||
/// keep enter-to-submit. Shared by the runtime edit path and the app
|
||||
/// `on_input` dispatch so retained text and the model hear the same edit.
|
||||
pub fn widgetKeyboardNewlineTextEditEvent(widget: Widget, event: WidgetKeyboardEvent) ?TextInputEvent {
|
||||
if (widget.kind != .textarea) return null;
|
||||
if (event.phase != .key_down or event.text.len != 0) return null;
|
||||
if (event.modifiers.control or event.modifiers.alt or event.modifiers.super) return null;
|
||||
if (!std.ascii.eqlIgnoreCase(event.key, "enter") and !std.ascii.eqlIgnoreCase(event.key, "return")) return null;
|
||||
if (widget.submit_on_enter and !event.modifiers.shift) return null;
|
||||
return .{ .insert_text = "\n" };
|
||||
}
|
||||
|
||||
@@ -140,7 +141,7 @@ pub fn widgetKeyboardNewlineTextEditEvent(kind: WidgetKind, event: WidgetKeyboar
|
||||
/// wider width winning exact ties (4-space files also divide by 2).
|
||||
/// Ambiguous or unindented source falls back to two spaces.
|
||||
pub fn widgetCodeTabTextEditEvent(widget: Widget, event: WidgetKeyboardEvent) ?TextInputEvent {
|
||||
if (widget.kind != .textarea or !widget.code_editor or widget.state.disabled) return null;
|
||||
if (widget.kind != .textarea or !widget.runtime_flags.code_editor or widget.state.disabled) return null;
|
||||
if (event.phase != .key_down or event.focus_moved or event.text.len != 0) return null;
|
||||
if (event.modifiers.shift or event.modifiers.hasNavigationModifier()) return null;
|
||||
if (!std.ascii.eqlIgnoreCase(event.key, "tab")) return null;
|
||||
|
||||
@@ -426,6 +426,7 @@ pub const DesignTokens = token_model.DesignTokens;
|
||||
pub const WidgetKind = widget_model.WidgetKind;
|
||||
pub const WidgetCursor = widget_model.WidgetCursor;
|
||||
pub const WidgetState = widget_model.WidgetState;
|
||||
pub const WidgetRuntimeFlags = widget_model.WidgetRuntimeFlags;
|
||||
pub const WidgetLayoutMotion = widget_model.WidgetLayoutMotion;
|
||||
pub const WidgetRenderState = widget_model.WidgetRenderState;
|
||||
pub const WidgetMainAlignment = widget_model.WidgetMainAlignment;
|
||||
|
||||
@@ -247,6 +247,45 @@ test "a later visual-line page retains an over-capacity wrapped paragraph" {
|
||||
try testing.expect(!later.truncated);
|
||||
}
|
||||
|
||||
test "ordinary span paragraphs paint a visible page after the line cap" {
|
||||
const source = ("x\n" ** 139) ++ "TAIL_SENTINEL";
|
||||
const spans = [_]TextSpan{.{ .text = source }};
|
||||
const tokens = canvas.DesignTokens{};
|
||||
const line_height = tokens.typography.body_size * 1.25;
|
||||
const children = [_]canvas.Widget{.{
|
||||
.id = 2,
|
||||
.kind = .text,
|
||||
// Put the final visual lines inside the scroll viewport while the
|
||||
// first 128-line storage page is far above it.
|
||||
.frame = geometry.RectF.init(0, -(139 * line_height) + 20, 100, 140 * line_height),
|
||||
.text = source,
|
||||
.spans = &spans,
|
||||
}};
|
||||
const root = canvas.Widget{
|
||||
.id = 1,
|
||||
.kind = .scroll_view,
|
||||
.frame = geometry.RectF.init(0, 0, 100, 100),
|
||||
.runtime_flags = .{ .native_scroll = true },
|
||||
.children = &children,
|
||||
};
|
||||
|
||||
var commands: [256]canvas.CanvasCommand = undefined;
|
||||
var builder = canvas.Builder.init(&commands);
|
||||
try canvas.emitWidgetTree(&builder, root, tokens);
|
||||
|
||||
var saw_tail = false;
|
||||
var text_commands: usize = 0;
|
||||
for (builder.displayList().commands) |command| {
|
||||
if (command != .draw_text) continue;
|
||||
text_commands += 1;
|
||||
if (std.mem.indexOf(u8, command.draw_text.text, "TAIL_") != null) saw_tail = true;
|
||||
}
|
||||
try testing.expect(saw_tail);
|
||||
// Viewport paging should not charge the 128 offscreen runs to the
|
||||
// display list merely to reach the tail.
|
||||
try testing.expect(text_commands < 16);
|
||||
}
|
||||
|
||||
const VariableClusterMeasure = struct {
|
||||
fn advance(text: []const u8) f32 {
|
||||
return switch (text[0]) {
|
||||
|
||||
@@ -605,6 +605,12 @@ pub fn Ui(comptime Msg: type) type {
|
||||
/// re-steals focus from the user. Only focusable widgets
|
||||
/// (interactive controls) can take it.
|
||||
autofocus: bool = false,
|
||||
/// Textarea Enter policy (markup `submit-on-enter`): plain
|
||||
/// Enter submits through `on_submit`, while Shift+Enter
|
||||
/// keeps the multiline newline path. False preserves the
|
||||
/// default textarea contract (Enter inserts; primary+Enter
|
||||
/// submits). Meaningless on every other element.
|
||||
submit_on_enter: bool = false,
|
||||
variant: canvas.WidgetVariant = .default,
|
||||
size: canvas.WidgetSize = .default,
|
||||
/// Definite width: the widget is exactly this wide (the value
|
||||
@@ -620,6 +626,11 @@ pub fn Ui(comptime Msg: type) type {
|
||||
/// Split panes use it to constrain the divider drag (the
|
||||
/// clamp band derives from both panes' floors).
|
||||
min_width: f32 = 0,
|
||||
/// Width ceiling WITHOUT the definite-min side of `width`:
|
||||
/// the widget fills or hugs normally until this bound, then
|
||||
/// stops growing. Pair with a centered flex parent for a
|
||||
/// responsive content column.
|
||||
max_width: f32 = 0,
|
||||
grow: f32 = 0,
|
||||
gap: f32 = 0,
|
||||
padding: ?f32 = null,
|
||||
@@ -1469,7 +1480,7 @@ pub fn Ui(comptime Msg: type) type {
|
||||
if (self.msgForTextEdit(target_id, stamped)) |msg| return msg;
|
||||
} else {
|
||||
const locally_derived = canvas.widgetCodeTabTextEditEvent(widget, keyboard) orelse
|
||||
canvas.widgetKeyboardNewlineTextEditEvent(widget.kind, keyboard) orelse
|
||||
canvas.widgetKeyboardNewlineTextEditEvent(widget, keyboard) orelse
|
||||
keyboard.textEditEvent();
|
||||
if (locally_derived) |text_edit| {
|
||||
// Direct Tree consumers still sanitize locally:
|
||||
@@ -2456,7 +2467,7 @@ pub fn Ui(comptime Msg: type) type {
|
||||
else
|
||||
0;
|
||||
if (diff_lines) |lines| editor.widget.setCodeDiffLines(lines);
|
||||
editor.widget.code_editor = true;
|
||||
editor.widget.runtime_flags.code_editor = true;
|
||||
editor.widget.code_language = options.language;
|
||||
editor.widget.layout.clip_content = true;
|
||||
return editor;
|
||||
@@ -3690,6 +3701,7 @@ pub fn Ui(comptime Msg: type) type {
|
||||
.text_alignment = options.text_alignment,
|
||||
.text_overflow = options.overflow,
|
||||
.autofocus = options.autofocus,
|
||||
.submit_on_enter = options.submit_on_enter,
|
||||
.image_id = options.image,
|
||||
.value = options.value,
|
||||
.value_x = options.value_x,
|
||||
@@ -3728,7 +3740,10 @@ pub fn Ui(comptime Msg: type) type {
|
||||
// is the exception: width documents the initial width
|
||||
// and the engine's drag handle keeps writing larger
|
||||
// frames past it.
|
||||
.max_size = if (kind == .resizable) .{} else .{ .width = options.width, .height = options.height },
|
||||
.max_size = if (kind == .resizable) .{} else .{
|
||||
.width = if (options.width > 0) options.width else options.max_width,
|
||||
.height = options.height,
|
||||
},
|
||||
},
|
||||
.style = options.style,
|
||||
.semantics = options.semantics,
|
||||
@@ -3825,7 +3840,11 @@ fn isSubmitKeyboard(widget: Widget, keyboard: canvas.WidgetKeyboardEvent) bool {
|
||||
// Multi-line entry: Enter edits (newline), so submit rides the
|
||||
// primary chord — cmd+Enter on macOS, ctrl+Enter elsewhere.
|
||||
// Shift/alt variants stay free for apps.
|
||||
.textarea => keyboard.modifiers.hasCommandModifier() and !keyboard.modifiers.alt and !keyboard.modifiers.shift,
|
||||
.textarea => if (widget.submit_on_enter)
|
||||
(!keyboard.modifiers.hasNavigationModifier() and !keyboard.modifiers.shift) or
|
||||
(keyboard.modifiers.hasCommandModifier() and !keyboard.modifiers.alt and !keyboard.modifiers.shift)
|
||||
else
|
||||
keyboard.modifiers.hasCommandModifier() and !keyboard.modifiers.alt and !keyboard.modifiers.shift,
|
||||
// List rows: plain Enter is the row's primary action when the
|
||||
// app binds `on_submit` (play the track, open the record); the
|
||||
// select activation keeps Space. `msgForKeyboard` resolves the
|
||||
|
||||
@@ -1175,6 +1175,8 @@ pub fn deadHandlerOnNonHitTarget(attr_name: []const u8) bool {
|
||||
|
||||
pub const autofocus_element_message = "autofocus is only supported on focusable controls (text fields, buttons, checkboxes, ...) - it moves keyboard focus to the element when it mounts or when the flag turns on, and nothing about this element can take focus";
|
||||
|
||||
pub const submit_on_enter_element_message = "submit-on-enter is only supported on textarea - it makes plain Enter dispatch on-submit while Shift+Enter inserts a newline; single-line fields already submit on Enter, and other elements have no multiline Enter policy";
|
||||
|
||||
pub const non_hit_target_handler_message = "on-change/on-submit/on-input never fire here: this element has no control or text behavior - put them on a control (input, checkbox, slider) inside it (on-press/on-double-press/on-toggle are fine anywhere: a bound press handler makes any element pressable, and clicks on plain text or icons inside it fall through to it)";
|
||||
|
||||
/// Elements whose widget kind layers its children on top of each other
|
||||
@@ -3281,6 +3283,19 @@ fn validateNode(document: MarkupDocument, node: MarkupNode, parent_element: ?[]c
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (std.mem.eql(u8, attribute.name, "submit-on-enter")) {
|
||||
// Only textarea has two meaningful Enter gestures:
|
||||
// submit or insert a newline. Single-line fields
|
||||
// already submit on plain Enter, and the option would
|
||||
// be dead on every other kind.
|
||||
if (!std.mem.eql(u8, node.name, "textarea")) {
|
||||
return attrError(node, attribute, submit_on_enter_element_message);
|
||||
}
|
||||
if (attrExpressionError(attribute.value, invalid_expression_message)) |message| {
|
||||
return attrError(node, attribute, message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (std.mem.eql(u8, attribute.name, "icon")) {
|
||||
// Inline vector icon, scoped to the labeled
|
||||
// interactive elements that render it themselves
|
||||
|
||||
@@ -373,6 +373,15 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
|
||||
}
|
||||
}
|
||||
}
|
||||
// Interpreter parity: only textarea has a configurable
|
||||
// Enter-vs-newline policy.
|
||||
if (kind != .textarea) {
|
||||
for (node.attrs) |attribute| {
|
||||
if (std.mem.eql(u8, attribute.name, "submit-on-enter")) {
|
||||
fail(node, markup.submit_on_enter_element_message);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Interpreter parity: the overflow policy's closed
|
||||
// literal vocabulary. A compile error here; bindings
|
||||
// resolve at runtime like any enum option.
|
||||
|
||||
@@ -1158,7 +1158,7 @@ test "compiled code element matches the interpreter and Ui.code" {
|
||||
try expectSameTree(fixture.CodeMsg, editable_hand, editable_interpreted);
|
||||
try expectSameTree(fixture.CodeMsg, editable_hand, editable_compiled);
|
||||
try testing.expectEqual(canvas.WidgetKind.textarea, editable_compiled.root.kind);
|
||||
try testing.expect(editable_compiled.root.code_editor);
|
||||
try testing.expect(editable_compiled.root.runtime_flags.code_editor);
|
||||
}
|
||||
|
||||
// ------------------------------------------- template/use + style parity
|
||||
|
||||
@@ -314,6 +314,10 @@ test "structural validation reports positions for grammar misuse" {
|
||||
var autofocus_parser = markup.Parser.init(arena_state.allocator(), autofocus_source);
|
||||
try testing.expectEqual(@as(?markup.MarkupErrorInfo, null), markup.validate(try autofocus_parser.parse()));
|
||||
|
||||
const submit_on_enter_source = "<column>\n <textarea submit-on-enter=\"true\" label=\"Prompt\" on-input=\"edit\" on-submit=\"send\" />\n</column>";
|
||||
var submit_on_enter_parser = markup.Parser.init(arena_state.allocator(), submit_on_enter_source);
|
||||
try testing.expectEqual(@as(?markup.MarkupErrorInfo, null), markup.validate(try submit_on_enter_parser.parse()));
|
||||
|
||||
const cases = [_]struct { source: []const u8, message: []const u8 }{
|
||||
.{ .source = "<column>\n <weird />\n</column>", .message = "unknown element" },
|
||||
.{ .source = "<column bogus=\"1\" />", .message = "unknown attribute" },
|
||||
@@ -362,6 +366,7 @@ test "structural validation reports positions for grammar misuse" {
|
||||
// elements can never take the keyboard.
|
||||
.{ .source = "<column>\n <row autofocus=\"true\">\n <text>x</text>\n </row>\n</column>", .message = markup.autofocus_element_message },
|
||||
.{ .source = "<column>\n <badge autofocus=\"true\">3</badge>\n</column>", .message = markup.autofocus_element_message },
|
||||
.{ .source = "<column>\n <text-field submit-on-enter=\"true\" label=\"Prompt\" />\n</column>", .message = markup.submit_on_enter_element_message },
|
||||
};
|
||||
for (cases) |case| {
|
||||
var case_parser = markup.Parser.init(arena_state.allocator(), case.source);
|
||||
|
||||
@@ -364,6 +364,15 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only textarea has a configurable Enter-vs-newline policy.
|
||||
// Mirrors validation and the compiled engine.
|
||||
if (kind != .textarea) {
|
||||
for (node.attrs) |attribute| {
|
||||
if (std.mem.eql(u8, attribute.name, "submit-on-enter")) {
|
||||
return self.failNode(node, markup.submit_on_enter_element_message);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The overflow policy's closed literal vocabulary. Mirrors
|
||||
// the validator and the compiled engine's compile error;
|
||||
// bindings resolve below like any enum option.
|
||||
|
||||
@@ -2025,7 +2025,7 @@ test "code markup builds the reusable component with opt-in numbers and horizont
|
||||
var editable_ui = CodeUi.init(arena);
|
||||
const editable_tree = try editable_ui.finalize(try view.build(&editable_ui, &editable_model));
|
||||
try testing.expectEqual(canvas.WidgetKind.textarea, editable_tree.root.kind);
|
||||
try testing.expect(editable_tree.root.code_editor);
|
||||
try testing.expect(editable_tree.root.runtime_flags.code_editor);
|
||||
try testing.expectEqual(@as(usize, 1), editable_tree.root.spans.len);
|
||||
try testing.expectEqual(code_model.Language.tsx, editable_tree.root.code_language);
|
||||
try testing.expect(editable_tree.root.text_no_wrap);
|
||||
@@ -3356,7 +3356,7 @@ pub const pane_markup_source =
|
||||
\\ </panel>
|
||||
\\ </for>
|
||||
\\ </tree>
|
||||
\\ <column min-width="120">
|
||||
\\ <column min-width="120" max-width="720">
|
||||
\\ <text>Editor</text>
|
||||
\\ </column>
|
||||
\\</split>
|
||||
@@ -3365,7 +3365,7 @@ pub const pane_markup_source =
|
||||
pub fn handPaneView(ui: *PaneUi, model: *const PaneModel) PaneUi.Node {
|
||||
return ui.split(.{ .value = model.sidebar_fraction, .on_resize = PaneUi.valueMsg(.sidebar_resized) }, .{
|
||||
ui.tree(.{ .semantics = .{ .label = "Folders" } }, ui.each(PaneModel.folders[0..], Folder.key, folderRow)),
|
||||
ui.column(.{ .min_width = 120 }, .{
|
||||
ui.column(.{ .min_width = 120, .max_width = 720 }, .{
|
||||
ui.text(.{}, "Editor"),
|
||||
}),
|
||||
});
|
||||
@@ -3427,10 +3427,11 @@ test "markup split and tree build the hand-written view with the divider synthes
|
||||
}).?.select_folder);
|
||||
try testing.expectEqual(@as(u32, 1), markup_tree.msgFor(row.id, .toggle).?.toggle_folder);
|
||||
|
||||
// min-width lands as a floor only (no definite max).
|
||||
// The independent responsive bounds land without becoming a
|
||||
// definite width.
|
||||
const editor = markup_tree.root.children[2];
|
||||
try testing.expectEqual(@as(f32, 120), editor.layout.min_size.width);
|
||||
try testing.expectEqual(@as(f32, 0), editor.layout.max_size.width);
|
||||
try testing.expectEqual(@as(f32, 720), editor.layout.max_size.width);
|
||||
}
|
||||
|
||||
test "split and tree misuse is validated with teaching messages" {
|
||||
@@ -4017,7 +4018,7 @@ pub const ComposerUi = canvas.Ui(ComposerMsg);
|
||||
pub const composer_markup_source =
|
||||
\\<column gap="8">
|
||||
\\ <input-group label="Message composer" height="120">
|
||||
\\ <textarea text="{draft}" placeholder="Type a message" on-input="edit" label="Message" />
|
||||
\\ <textarea text="{draft}" placeholder="Type a message" submit-on-enter="true" on-input="edit" label="Message" />
|
||||
\\ <input-group-actions>
|
||||
\\ <button icon="plus" variant="ghost" size="icon" on-press="attach" label="Attach"></button>
|
||||
\\ <spacer grow="1" />
|
||||
@@ -4035,6 +4036,7 @@ pub fn handComposerView(ui: *ComposerUi, model: *const ComposerModel) ComposerUi
|
||||
const entry = ui.el(.textarea, .{
|
||||
.text = model.draft(),
|
||||
.placeholder = "Type a message",
|
||||
.submit_on_enter = true,
|
||||
.on_input = ComposerUi.inputMsg(.edit),
|
||||
.semantics = .{ .label = "Message" },
|
||||
}, .{});
|
||||
@@ -4089,6 +4091,7 @@ test "the input-group element builds the hand-written Ui.inputGroup tree" {
|
||||
try testing.expectEqual(canvas.WidgetKind.textarea, entry.kind);
|
||||
try testing.expectEqualStrings("hello", entry.text);
|
||||
try testing.expectEqualStrings("Type a message", entry.placeholder);
|
||||
try testing.expect(entry.submit_on_enter);
|
||||
try testing.expectEqual(@as(f32, 1), entry.layout.grow);
|
||||
try testing.expectEqual(@as(u8, 0), entry.style.background.?.a);
|
||||
try testing.expectEqual(@as(u8, 0), entry.style.border.?.a);
|
||||
|
||||
@@ -579,6 +579,14 @@ pub const attrs = [_]AttrInfo{
|
||||
// Markdown's caller-owned source -> registered ImageId mapping. The
|
||||
// markdown rule hook scopes and type-checks the iterable binding.
|
||||
.{ .code = 96, .name = "images", .class = .binding_only, .group = .composite },
|
||||
// Textarea Enter policy (textarea only): false preserves the
|
||||
// multiline default (Enter inserts, primary+Enter submits); true is
|
||||
// the chat-composer convention (Enter submits, Shift+Enter inserts).
|
||||
.{ .code = 97, .name = "submit-on-enter", .class = .flag, .group = .option, .field = "submit_on_enter" },
|
||||
// Responsive width ceiling: unlike definite `width`, this leaves the
|
||||
// minimum unconstrained so a capped element still shrinks with a
|
||||
// narrow parent.
|
||||
.{ .code = 98, .name = "max-width", .class = .number, .group = .option, .field = "max_width" },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------- events
|
||||
|
||||
@@ -22,7 +22,7 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed"
|
||||
// the new fingerprint ONLY for additions; renames/renumbers are
|
||||
// schema-version-bump events, not silent edits.
|
||||
try testing.expectEqual(@as(usize, 70), schema.elements.len);
|
||||
try testing.expectEqual(@as(usize, 96), schema.attrs.len);
|
||||
try testing.expectEqual(@as(usize, 98), schema.attrs.len);
|
||||
try testing.expectEqual(@as(usize, 15), schema.events.len);
|
||||
// The element table runs through the span composite (64), the
|
||||
// bubble-reactions composite (65), the media surface (66), the
|
||||
@@ -46,9 +46,11 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed"
|
||||
// (90), line-numbers (91), and editable-code (92) declarations,
|
||||
// the flat disclosure-tree hierarchy level tree-level (93), and the
|
||||
// code-diff line sets added-lines (94) and removed-lines (95), and
|
||||
// Markdown's registered-image iterable binding images (96).
|
||||
// Markdown's registered-image iterable binding images (96), and the
|
||||
// textarea Enter policy submit-on-enter (97), and the responsive
|
||||
// layout ceiling max-width (98).
|
||||
try testing.expectEqual(
|
||||
@as(u64, 0xc0ab8db72d6e2110),
|
||||
@as(u64, 0xb8991dcb86471877),
|
||||
tableFingerprint(schema.AttrInfo, &schema.attrs),
|
||||
);
|
||||
// The event table runs through the pointer-hover containment pair
|
||||
|
||||
@@ -375,15 +375,17 @@ test "tree keyboard navigation can select without dispatching pointer activation
|
||||
}).?);
|
||||
}
|
||||
|
||||
test "textarea keyboard: enter edits a newline, submit rides the primary chord" {
|
||||
test "textarea keyboard: the default and chat-composer Enter policies stay distinct" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
var ui = InboxUi.init(arena_state.allocator());
|
||||
const tree = try ui.finalize(ui.column(.{ .gap = 8 }, .{
|
||||
ui.el(.textarea, .{ .on_input = InboxUi.inputMsg(.draft), .on_submit = .add }, .{}),
|
||||
ui.el(.textarea, .{ .submit_on_enter = true, .on_input = InboxUi.inputMsg(.draft), .on_submit = .add }, .{}),
|
||||
}));
|
||||
const textarea = findByKind(tree.root, .textarea).?;
|
||||
const textarea = tree.root.children[0];
|
||||
const prompt = tree.root.children[1];
|
||||
|
||||
// Plain Enter is an EDIT: the model's on_input hears the newline the
|
||||
// runtime applied to the retained text — never a submit.
|
||||
@@ -406,6 +408,13 @@ test "textarea keyboard: enter edits a newline, submit rides the primary chord"
|
||||
try testing.expectEqual(@as(?Msg, null), tree.msgForKeyboard(textarea.id, cmd_shift_enter));
|
||||
const alt_enter = canvas.WidgetKeyboardEvent{ .phase = .key_down, .key = "enter", .modifiers = .{ .alt = true } };
|
||||
try testing.expectEqual(@as(?Msg, null), tree.msgForKeyboard(textarea.id, alt_enter));
|
||||
|
||||
// A chat composer opts into plain Enter submission, but Shift+Enter
|
||||
// remains the explicit multiline gesture. The primary chord remains
|
||||
// accepted too, preserving the standard textarea shortcut.
|
||||
try testing.expectEqual(Msg.add, tree.msgForKeyboard(prompt.id, enter).?);
|
||||
try testing.expectEqualStrings("\n", tree.msgForKeyboard(prompt.id, shift_enter).?.draft.insert_text);
|
||||
try testing.expectEqual(Msg.add, tree.msgForKeyboard(prompt.id, cmd_enter).?);
|
||||
}
|
||||
|
||||
test "single-line keyboard fallback derivation sanitizes line breaks like the runtime seam" {
|
||||
@@ -758,13 +767,14 @@ test "wrapped text reserves its wrapped height in a definite-width pane" {
|
||||
try testing.expect(below_frame.?.y >= wrapped_frame.?.y + wrapped_frame.?.height);
|
||||
}
|
||||
|
||||
test "explicit sizes are definite except on resizable" {
|
||||
test "explicit sizes are definite, max width is a ceiling, and resizable keeps its exception" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
var ui = InboxUi.init(arena_state.allocator());
|
||||
const tree = try ui.finalize(ui.column(.{}, .{
|
||||
ui.el(.panel, .{ .width = 240, .height = 40 }, .{}),
|
||||
ui.el(.panel, .{ .max_width = 720 }, .{}),
|
||||
// Resizable keeps width as the initial/min width only: the engine's
|
||||
// drag handle writes larger frames past it.
|
||||
ui.el(.resizable, .{ .width = 240 }, .{}),
|
||||
@@ -776,7 +786,11 @@ test "explicit sizes are definite except on resizable" {
|
||||
try testing.expectEqual(@as(f32, 40), panel.layout.min_size.height);
|
||||
try testing.expectEqual(@as(f32, 40), panel.layout.max_size.height);
|
||||
|
||||
const resizable = tree.root.children[1];
|
||||
const capped = tree.root.children[1];
|
||||
try testing.expectEqual(@as(f32, 0), capped.layout.min_size.width);
|
||||
try testing.expectEqual(@as(f32, 720), capped.layout.max_size.width);
|
||||
|
||||
const resizable = tree.root.children[2];
|
||||
try testing.expectEqual(@as(f32, 240), resizable.layout.min_size.width);
|
||||
try testing.expectEqual(@as(f32, 0), resizable.layout.max_size.width);
|
||||
}
|
||||
|
||||
@@ -206,6 +206,9 @@ fn emitWidgetLayoutDragPreview(builder: *Builder, layout: anytype, tokens: Desig
|
||||
|
||||
var preview_state = WidgetRenderState{
|
||||
.rendering_drag_preview = true,
|
||||
.drag_preview_id = source_id,
|
||||
.drag_preview_origin = state.drag_preview_origin,
|
||||
.drag_preview_offset = state.drag_preview_offset,
|
||||
};
|
||||
// Keep disclosure content in the preview in the same visible phase as
|
||||
// the standing tree, while dropping focus/hover/press chrome from the
|
||||
@@ -215,18 +218,31 @@ fn emitWidgetLayoutDragPreview(builder: *Builder, layout: anytype, tokens: Desig
|
||||
const ancestor_transform = widgetLayoutNodeAncestorEmissionTransform(layout, source_index) orelse return error.InvalidTransform;
|
||||
const wrap_ancestor_transform = !affinesEqual(ancestor_transform, Affine.identity());
|
||||
const inverse_ancestor_transform = if (wrap_ancestor_transform) ancestor_transform.inverse() orelse return error.InvalidTransform else Affine.identity();
|
||||
const current_frame = layout.nodes[source_index].frame.normalized();
|
||||
const current_origin = ancestor_transform.transformPoint(geometry.PointF.init(current_frame.x, current_frame.y));
|
||||
const source_layout_origin = state.drag_preview_origin orelse geometry.PointF.init(current_frame.x, current_frame.y);
|
||||
const source_origin = ancestor_transform.transformPoint(source_layout_origin);
|
||||
const translate_x = source_origin.x + state.drag_preview_offset.dx - current_origin.x;
|
||||
const translate_y = source_origin.y + state.drag_preview_offset.dy - current_origin.y;
|
||||
const translation = Affine.translate(translate_x, translate_y);
|
||||
const translation = widgetLayoutDragPreviewTranslation(layout, source_index, state, ancestor_transform);
|
||||
try builder.transform(translation);
|
||||
if (wrap_ancestor_transform) try builder.transform(ancestor_transform);
|
||||
try emitWidgetLayoutNode(builder, layout, source_index, tokens, preview_state, .none);
|
||||
if (wrap_ancestor_transform) try builder.transform(inverse_ancestor_transform);
|
||||
try builder.transform(Affine.translate(-translate_x, -translate_y));
|
||||
try builder.transform(Affine.translate(-translation.tx, -translation.ty));
|
||||
}
|
||||
|
||||
/// The window-space translation around a floating drag preview. The same
|
||||
/// value drives both the builder stack and span visibility below, so a rich
|
||||
/// text child is culled at its lifted pose rather than its standing slot.
|
||||
fn widgetLayoutDragPreviewTranslation(
|
||||
layout: anytype,
|
||||
source_index: usize,
|
||||
state: WidgetRenderState,
|
||||
ancestor_transform: Affine,
|
||||
) Affine {
|
||||
const current_frame = layout.nodes[source_index].frame.normalized();
|
||||
const current_origin = ancestor_transform.transformPoint(geometry.PointF.init(current_frame.x, current_frame.y));
|
||||
const source_layout_origin = state.drag_preview_origin orelse geometry.PointF.init(current_frame.x, current_frame.y);
|
||||
const source_origin = ancestor_transform.transformPoint(source_layout_origin);
|
||||
return Affine.translate(
|
||||
source_origin.x + state.drag_preview_offset.dx - current_origin.x,
|
||||
source_origin.y + state.drag_preview_offset.dy - current_origin.y,
|
||||
);
|
||||
}
|
||||
|
||||
/// The union of the layout's root-node frames: the whole laid-out
|
||||
@@ -265,6 +281,46 @@ fn widgetLayoutNodeEmissionTransform(layout: anytype, node_index: usize) ?Affine
|
||||
return transform;
|
||||
}
|
||||
|
||||
/// The transform active while `node_index` paints in this frame. Unlike the
|
||||
/// standing emission transform above, this includes presentation-only layout
|
||||
/// motion and the window-level translation around a floating drag preview.
|
||||
fn widgetLayoutNodePresentationTransform(
|
||||
layout: anytype,
|
||||
node_index: usize,
|
||||
state: WidgetRenderState,
|
||||
outer_transform: Affine,
|
||||
) ?Affine {
|
||||
// Static/ordinary frames are overwhelmingly common. Keep their culling
|
||||
// path byte-for-byte with the standing transform walk; only late passes
|
||||
// and active layout motion pay for presentation-state lookups.
|
||||
if (!state.rendering_drag_preview and state.layout_motions.len == 0) {
|
||||
return widgetLayoutNodeEmissionTransform(layout, node_index);
|
||||
}
|
||||
if (node_index >= layout.nodes.len) return null;
|
||||
var indices: [widget_layout.max_widget_depth]usize = undefined;
|
||||
var len: usize = 0;
|
||||
var current: ?usize = node_index;
|
||||
while (current) |index| {
|
||||
if (index >= layout.nodes.len or len >= indices.len) return null;
|
||||
indices[len] = index;
|
||||
len += 1;
|
||||
if (widget_tree.widgetIsAnchored(layout.nodes[index].widget)) break;
|
||||
current = layout.nodes[index].parent_index;
|
||||
}
|
||||
|
||||
var transform = outer_transform;
|
||||
while (len > 0) {
|
||||
len -= 1;
|
||||
const widget = layout.nodes[indices[len]].widget;
|
||||
const motion = state.layoutMotionOffset(widget.id);
|
||||
if (motion.dx != 0 or motion.dy != 0) {
|
||||
transform = transform.multiply(Affine.translate(motion.dx, motion.dy));
|
||||
}
|
||||
transform = transform.multiply(widgetTransform(widget));
|
||||
}
|
||||
return transform;
|
||||
}
|
||||
|
||||
/// The transform stack a node inherits at its ordinary paint position. A
|
||||
/// drag preview is hoisted to the window-level late pass to escape clipping,
|
||||
/// so it must explicitly restore this stack before painting the source.
|
||||
@@ -279,24 +335,45 @@ fn widgetLayoutNodeAncestorEmissionTransform(layout: anytype, node_index: usize)
|
||||
|
||||
/// The rectangular part of one layout node that can reach the surface,
|
||||
/// returned in the node's untransformed layout coordinate space. Window
|
||||
/// and ancestor clip bounds are intersected in device space, then mapped
|
||||
/// back through the exact transform stack active at node emission. Code
|
||||
/// paragraphs use this so transformed sources neither disappear nor lose
|
||||
/// later pages while still charging only visible runs to display budgets.
|
||||
fn widgetLayoutNodeVisibleBounds(layout: anytype, node_index: usize, bounds: geometry.RectF) ?geometry.RectF {
|
||||
/// and active ancestor clip bounds are intersected in device space, then
|
||||
/// mapped back through the exact PRESENTATION transform stack. Floating
|
||||
/// drag previews and clip-escaping landing motions stop at their lifted
|
||||
/// subtree root, so the culling policy matches the late unclipped paint pass.
|
||||
fn widgetLayoutNodeVisibleBounds(
|
||||
layout: anytype,
|
||||
node_index: usize,
|
||||
bounds: geometry.RectF,
|
||||
state: WidgetRenderState,
|
||||
) ?geometry.RectF {
|
||||
if (node_index >= layout.nodes.len) return null;
|
||||
var device_visible = (widgetLayoutRootBounds(layout) orelse return null).normalized();
|
||||
const has_layout_motion = state.layout_motions.len > 0;
|
||||
const outer_transform = if (state.rendering_drag_preview) blk: {
|
||||
const preview_id = state.drag_preview_id orelse return null;
|
||||
const preview_index = widget_tree.widgetIndexById(layout, preview_id) orelse return null;
|
||||
const ancestor_transform = widgetLayoutNodeAncestorEmissionTransform(layout, preview_index) orelse return null;
|
||||
break :blk widgetLayoutDragPreviewTranslation(layout, preview_index, state, ancestor_transform);
|
||||
} else Affine.identity();
|
||||
|
||||
var current = node_index;
|
||||
while (true) {
|
||||
// Hoisted anchored surfaces escape their original ancestor clips,
|
||||
// but the window intersection still bounds display-list demand.
|
||||
if (widget_tree.widgetIsAnchored(layout.nodes[current].widget)) break;
|
||||
const current_widget = layout.nodes[current].widget;
|
||||
const is_drag_preview_root = state.rendering_drag_preview and
|
||||
state.drag_preview_id != null and
|
||||
state.drag_preview_id.? == current_widget.id;
|
||||
// Every late window-level pass escapes clips ABOVE its lifted root,
|
||||
// but nested clips inside that subtree still constrain descendants.
|
||||
if (widget_tree.widgetIsAnchored(current_widget) or
|
||||
is_drag_preview_root or
|
||||
has_layout_motion and state.layoutMotionEscapesAncestorClips(current_widget.id))
|
||||
{
|
||||
break;
|
||||
}
|
||||
const parent_index = layout.nodes[current].parent_index orelse break;
|
||||
if (parent_index >= layout.nodes.len) return null;
|
||||
const parent = layout.nodes[parent_index];
|
||||
if (widgetClipsContent(parent.widget)) {
|
||||
const parent_transform = widgetLayoutNodeEmissionTransform(layout, parent_index) orelse return null;
|
||||
const parent_transform = widgetLayoutNodePresentationTransform(layout, parent_index, state, outer_transform) orelse return null;
|
||||
const device_clip = parent_transform.transformRect(parent.frame.normalized());
|
||||
device_visible = geometry.RectF.intersection(device_visible, device_clip);
|
||||
if (device_visible.isEmpty()) return null;
|
||||
@@ -304,7 +381,7 @@ fn widgetLayoutNodeVisibleBounds(layout: anytype, node_index: usize, bounds: geo
|
||||
current = parent_index;
|
||||
}
|
||||
|
||||
const transform = widgetLayoutNodeEmissionTransform(layout, node_index) orelse return null;
|
||||
const transform = widgetLayoutNodePresentationTransform(layout, node_index, state, outer_transform) orelse return null;
|
||||
const inverse = transform.inverse() orelse return null;
|
||||
const local_visible = inverse.transformRect(device_visible);
|
||||
const clipped = geometry.RectF.intersection(bounds.normalized(), local_visible.normalized());
|
||||
@@ -397,6 +474,16 @@ fn emitWidgetDepthContent(builder: *Builder, widget: Widget, tokens: DesignToken
|
||||
try emitVisibleCodeTextSpansWidget(builder, paint_widget, tokens, clipped, .{});
|
||||
}
|
||||
}
|
||||
} else if (paint_widget.spans.len > 0) {
|
||||
if (tree_visible_bounds) |visible_bounds| {
|
||||
const clipped = geometry.RectF.intersection(
|
||||
paint_widget.frame.normalized(),
|
||||
visible_bounds.normalized(),
|
||||
);
|
||||
if (!clipped.isEmpty()) {
|
||||
try emitVisibleTextSpansWidget(builder, paint_widget, tokens, clipped);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try emitTextWidget(builder, paint_widget, tokens);
|
||||
}
|
||||
@@ -411,7 +498,7 @@ fn emitWidgetDepthContent(builder: *Builder, widget: Widget, tokens: DesignToken
|
||||
.icon_button => try widget_render_controls.emitIconButtonWidget(builder, paint_widget, tokens),
|
||||
.select => try widget_render_controls.emitSelectWidget(builder, paint_widget, tokens),
|
||||
.input, .text_field => try widget_render_controls.emitTextFieldWidget(builder, paint_widget, tokens),
|
||||
.textarea => if (paint_widget.code_editor)
|
||||
.textarea => if (paint_widget.runtime_flags.code_editor)
|
||||
try emitCodeEditorWidget(builder, paint_widget, tokens)
|
||||
else
|
||||
try widget_render_controls.emitTextFieldWidget(builder, paint_widget, tokens),
|
||||
@@ -714,7 +801,7 @@ fn emitWidgetLayoutNodeContent(
|
||||
try emitWidgetLayoutChildren(builder, layout, node_index, tokens, state);
|
||||
try builder.popClip();
|
||||
// Native scroll drivers own the (OS overlay) scrollbar.
|
||||
if (!paint_widget.native_scroll) {
|
||||
if (!paint_widget.runtime_flags.native_scroll) {
|
||||
try widget_render_scroll.emitScrollViewScrollbars(
|
||||
builder,
|
||||
paint_widget.frame,
|
||||
@@ -780,9 +867,13 @@ fn emitWidgetLayoutNodeContent(
|
||||
.menu_surface, .dropdown_menu => try widget_render_surfaces.emitMenuSurfaceWidgetChrome(builder, paint_widget, tokens),
|
||||
.text => {
|
||||
if (isSyntaxCodeParagraph(paint_widget)) {
|
||||
if (widgetLayoutNodeVisibleBounds(layout, node_index, paint_widget.frame)) |visible_bounds| {
|
||||
if (widgetLayoutNodeVisibleBounds(layout, node_index, paint_widget.frame, state)) |visible_bounds| {
|
||||
try emitVisibleCodeTextSpansWidget(builder, paint_widget, tokens, visible_bounds, .{});
|
||||
}
|
||||
} else if (paint_widget.spans.len > 0) {
|
||||
if (widgetLayoutNodeVisibleBounds(layout, node_index, paint_widget.frame, state)) |visible_bounds| {
|
||||
try emitVisibleTextSpansWidget(builder, paint_widget, tokens, visible_bounds);
|
||||
}
|
||||
} else {
|
||||
try emitTextWidget(builder, paint_widget, tokens);
|
||||
}
|
||||
@@ -797,7 +888,7 @@ fn emitWidgetLayoutNodeContent(
|
||||
.icon_button => try widget_render_controls.emitIconButtonWidget(builder, paint_widget, tokens),
|
||||
.select => try widget_render_controls.emitSelectWidget(builder, paint_widget, tokens),
|
||||
.input, .text_field => try widget_render_controls.emitTextFieldWidget(builder, paint_widget, tokens),
|
||||
.textarea => if (paint_widget.code_editor)
|
||||
.textarea => if (paint_widget.runtime_flags.code_editor)
|
||||
try emitCodeEditorWidget(builder, paint_widget, tokens)
|
||||
else
|
||||
try widget_render_controls.emitTextFieldWidget(builder, paint_widget, tokens),
|
||||
@@ -871,7 +962,7 @@ fn emitWidgetLayoutScrollableChildren(
|
||||
// Native scroll drivers own the (OS overlay) scrollbar. These are
|
||||
// the virtualized containers — vertical machinery, so only the
|
||||
// vertical bar can exist.
|
||||
if (!widget.native_scroll) {
|
||||
if (!widget.runtime_flags.native_scroll) {
|
||||
try widget_render_scroll.emitScrollViewScrollbars(
|
||||
builder,
|
||||
widget.frame,
|
||||
@@ -1114,7 +1205,7 @@ fn emitScrollViewWidget(builder: *Builder, widget: Widget, tokens: DesignTokens,
|
||||
try emitWidgetChildren(builder, widget.children, tokens, depth);
|
||||
try builder.popClip();
|
||||
// Native scroll drivers own the (OS overlay) scrollbar.
|
||||
if (!widget.native_scroll) {
|
||||
if (!widget.runtime_flags.native_scroll) {
|
||||
try widget_render_scroll.emitScrollViewScrollbars(
|
||||
builder,
|
||||
widget.frame,
|
||||
@@ -1389,112 +1480,194 @@ fn emitStaticTextSelectionBounded(
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a span paragraph: one single-line text command per laid-out run
|
||||
/// plus thin fill rects for underline/strikethrough decorations. Runs and
|
||||
/// decorations get stable hashed command ids derived from the widget id
|
||||
/// and their ordinal, so retained diffing works across frames.
|
||||
/// Full-frame compatibility path for span-bearing composite leaves such
|
||||
/// as table cells. Ordinary `.text` nodes pass their real viewport below.
|
||||
fn emitTextSpansWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
|
||||
return emitVisibleTextSpansWidget(builder, widget, tokens, widget.frame);
|
||||
}
|
||||
|
||||
/// Draw the visible pages of an ordinary span paragraph: one single-line
|
||||
/// text command per laid-out run plus backgrounds and thin decoration
|
||||
/// rects. Layout keeps the full paragraph height, while painting pages the
|
||||
/// bounded run store through the current viewport. Without this split, a
|
||||
/// transcript longer than `max_text_span_lines_per_paragraph` reserved its
|
||||
/// real height but painted only its first page, leaving a blank tail after
|
||||
/// the scroll moved beyond line 128.
|
||||
fn emitVisibleTextSpansWidget(
|
||||
builder: *Builder,
|
||||
widget: Widget,
|
||||
tokens: DesignTokens,
|
||||
visible_bounds: geometry.RectF,
|
||||
) Error!void {
|
||||
const content = widget_metrics.widgetTextSpanContentFrame(widget, tokens);
|
||||
const layout_options = widget_metrics.widgetTextSpanLayoutOptions(
|
||||
widget,
|
||||
tokens,
|
||||
textWrapMaxWidth(tokens, content.width),
|
||||
);
|
||||
var runs: [text_spans_model.max_text_span_runs_per_paragraph]text_spans_model.TextSpanRun = undefined;
|
||||
const layout = text_spans_model.layoutTextSpans(
|
||||
widget.spans,
|
||||
layout_options,
|
||||
&runs,
|
||||
);
|
||||
const line_height = text_spans_model.textSpanLineHeight(widget.spans, layout_options);
|
||||
if (line_height <= 0 or !std.math.isFinite(line_height)) return;
|
||||
|
||||
try emitCodeLineDecorations(builder, widget, widget.spans, tokens, content, widget.frame, layout_options, null, true);
|
||||
// Span background highlights (intra-line diff emphasis): one
|
||||
// full-line-height rect per run, the same geometry selection rects
|
||||
// use, painted before selection and glyphs. Edge-snapped rects of
|
||||
// adjacent runs share their boundary, so equal backgrounds abut
|
||||
// without seams.
|
||||
for (layout.runs, 0..) |run, ordinal| {
|
||||
if (run.text.len == 0) continue;
|
||||
const background = widget.spans[run.span_index].background orelse continue;
|
||||
const bounds = text_spans_model.textSpanRunBounds(layout, run);
|
||||
try builder.fillRect(.{
|
||||
.id = textSpanBackgroundCommandId(widget.id, ordinal),
|
||||
.rect = pixelSnapGeometryRect(tokens, geometry.RectF.init(
|
||||
const first_visible_line: usize = if (std.math.isFinite(visible_bounds.y - content.y) and
|
||||
visible_bounds.y > content.y)
|
||||
@intFromFloat(@floor((visible_bounds.y - content.y) / line_height))
|
||||
else
|
||||
0;
|
||||
const last_visible_line: usize = if (std.math.isFinite(visible_bounds.maxY() - content.y) and
|
||||
visible_bounds.maxY() > content.y)
|
||||
@intFromFloat(@floor((visible_bounds.maxY() - content.y) / line_height))
|
||||
else
|
||||
first_visible_line;
|
||||
const lines_per_page = text_spans_model.max_text_span_lines_per_paragraph;
|
||||
const first_page_line = (first_visible_line / lines_per_page) * lines_per_page;
|
||||
|
||||
var runs: [text_spans_model.max_text_span_runs_per_paragraph]text_spans_model.TextSpanRun = undefined;
|
||||
|
||||
// Backgrounds from every visible page stay behind the paragraph's
|
||||
// selection layer and glyphs, preserving the original paint order.
|
||||
var page_first_line = first_page_line;
|
||||
while (true) {
|
||||
const layout = text_spans_model.layoutTextSpansFromLine(
|
||||
widget.spans,
|
||||
layout_options,
|
||||
page_first_line,
|
||||
&runs,
|
||||
);
|
||||
const page_index = page_first_line / lines_per_page;
|
||||
const ordinal_base = page_index *| text_spans_model.max_text_span_runs_per_paragraph;
|
||||
for (layout.runs, 0..) |run, ordinal| {
|
||||
if (run.text.len == 0) continue;
|
||||
const background = widget.spans[run.span_index].background orelse continue;
|
||||
const bounds = text_spans_model.textSpanRunBounds(layout, run);
|
||||
const frame = geometry.RectF.init(
|
||||
content.x + bounds.x,
|
||||
content.y + bounds.y,
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
)),
|
||||
.fill = colorFill(text_spans_model.textSpanColorValue(tokens.colors, background)),
|
||||
});
|
||||
);
|
||||
if (!frame.intersects(visible_bounds)) continue;
|
||||
try builder.fillRect(.{
|
||||
// Page zero keeps the historical ids byte-for-byte; later
|
||||
// pages occupy disjoint ordinal bands.
|
||||
.id = textSpanBackgroundCommandId(widget.id, ordinal_base +| ordinal),
|
||||
.rect = pixelSnapGeometryRect(tokens, frame),
|
||||
.fill = colorFill(text_spans_model.textSpanColorValue(tokens.colors, background)),
|
||||
});
|
||||
}
|
||||
const next_first_line = page_first_line +| lines_per_page;
|
||||
if (next_first_line <= page_first_line or
|
||||
next_first_line > last_visible_line or
|
||||
next_first_line >= layout.line_count)
|
||||
{
|
||||
break;
|
||||
}
|
||||
page_first_line = next_first_line;
|
||||
}
|
||||
|
||||
try emitStaticTextSelection(builder, widget, tokens);
|
||||
|
||||
var decoration_ordinal: usize = 0;
|
||||
for (layout.runs, 0..) |run, ordinal| {
|
||||
if (run.text.len == 0) continue;
|
||||
const span = widget.spans[run.span_index];
|
||||
const is_link = span.link.len > 0;
|
||||
const color = if (span.color) |ref|
|
||||
text_spans_model.textSpanColorValue(tokens.colors, ref)
|
||||
else if (is_link)
|
||||
widgetForegroundColor(widget, tokens, tokens.colors.accent)
|
||||
else
|
||||
widgetForegroundColor(widget, tokens, tokens.colors.text);
|
||||
const origin = pixelSnapTextPoint(tokens, geometry.PointF.init(content.x + run.x, content.y + run.baseline));
|
||||
try builder.drawText(.{
|
||||
.id = textSpanRunCommandId(widget.id, ordinal),
|
||||
.font_id = run.font_id,
|
||||
.size = run.size,
|
||||
.origin = origin,
|
||||
.color = color,
|
||||
.text = run.text,
|
||||
// Wrapping already happened at the span level (each run is one
|
||||
// line segment), so the options carry no wrap work — they carry
|
||||
// the measurement seam. Renderers that walk per-cluster
|
||||
// advances (the reference renderer behind every automation
|
||||
// screenshot) then advance with the same provider layout
|
||||
// positioned the runs with; without it a provider-kerned prose
|
||||
// run repainted at estimator advances overran the next span's
|
||||
// x and visually swallowed the inter-span space
|
||||
// ("remaining`experimental_`" -> "remainingexperimental_").
|
||||
.text_layout = .{
|
||||
.max_width = 0,
|
||||
.line_height = layout.line_height,
|
||||
.wrap = .none,
|
||||
.alignment = .start,
|
||||
.measure = tokens.text_measure,
|
||||
},
|
||||
});
|
||||
page_first_line = first_page_line;
|
||||
while (true) {
|
||||
const layout = text_spans_model.layoutTextSpansFromLine(
|
||||
widget.spans,
|
||||
layout_options,
|
||||
page_first_line,
|
||||
&runs,
|
||||
);
|
||||
const page_index = page_first_line / lines_per_page;
|
||||
const ordinal_base = page_index *| text_spans_model.max_text_span_runs_per_paragraph;
|
||||
const decoration_base = page_index *| (text_spans_model.max_text_span_runs_per_paragraph * 2);
|
||||
var decoration_ordinal: usize = 0;
|
||||
for (layout.runs, 0..) |run, ordinal| {
|
||||
if (run.text.len == 0) continue;
|
||||
const span = widget.spans[run.span_index];
|
||||
const is_link = span.link.len > 0;
|
||||
const underline_ordinal: ?usize = if (span.underline or is_link) blk: {
|
||||
const value = decoration_base +| decoration_ordinal;
|
||||
decoration_ordinal += 1;
|
||||
break :blk value;
|
||||
} else null;
|
||||
const strikethrough_ordinal: ?usize = if (span.strikethrough) blk: {
|
||||
const value = decoration_base +| decoration_ordinal;
|
||||
decoration_ordinal += 1;
|
||||
break :blk value;
|
||||
} else null;
|
||||
const bounds = text_spans_model.textSpanRunBounds(layout, run);
|
||||
const run_frame = geometry.RectF.init(
|
||||
content.x + bounds.x,
|
||||
content.y + bounds.y,
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
);
|
||||
if (!run_frame.intersects(visible_bounds)) continue;
|
||||
|
||||
const thickness = @max(1, tokens.stroke.hairline);
|
||||
if (span.underline or is_link) {
|
||||
try builder.fillRect(.{
|
||||
.id = textSpanDecorationCommandId(widget.id, decoration_ordinal),
|
||||
.rect = pixelSnapGeometryRect(tokens, geometry.RectF.init(
|
||||
content.x + run.x,
|
||||
content.y + run.baseline + @max(1, run.size * 0.1),
|
||||
run.width,
|
||||
thickness,
|
||||
)),
|
||||
.fill = colorFill(color),
|
||||
const color = if (span.color) |ref|
|
||||
text_spans_model.textSpanColorValue(tokens.colors, ref)
|
||||
else if (is_link)
|
||||
widgetForegroundColor(widget, tokens, tokens.colors.accent)
|
||||
else
|
||||
widgetForegroundColor(widget, tokens, tokens.colors.text);
|
||||
const origin = pixelSnapTextPoint(tokens, geometry.PointF.init(content.x + run.x, content.y + run.baseline));
|
||||
try builder.drawText(.{
|
||||
.id = textSpanRunCommandId(widget.id, ordinal_base +| ordinal),
|
||||
.font_id = run.font_id,
|
||||
.size = run.size,
|
||||
.origin = origin,
|
||||
.color = color,
|
||||
.text = run.text,
|
||||
// Wrapping already happened at the span level (each run is one
|
||||
// line segment), so the options carry no wrap work — they carry
|
||||
// the measurement seam. Renderers that walk per-cluster
|
||||
// advances (the reference renderer behind every automation
|
||||
// screenshot) then advance with the same provider layout
|
||||
// positioned the runs with; without it a provider-kerned prose
|
||||
// run repainted at estimator advances overran the next span's
|
||||
// x and visually swallowed the inter-span space
|
||||
// ("remaining`experimental_`" -> "remainingexperimental_").
|
||||
.text_layout = .{
|
||||
.max_width = 0,
|
||||
.line_height = layout.line_height,
|
||||
.wrap = .none,
|
||||
.alignment = .start,
|
||||
.measure = tokens.text_measure,
|
||||
},
|
||||
});
|
||||
decoration_ordinal += 1;
|
||||
|
||||
const thickness = @max(1, tokens.stroke.hairline);
|
||||
if (underline_ordinal) |decoration_id_ordinal| {
|
||||
try builder.fillRect(.{
|
||||
.id = textSpanDecorationCommandId(widget.id, decoration_id_ordinal),
|
||||
.rect = pixelSnapGeometryRect(tokens, geometry.RectF.init(
|
||||
content.x + run.x,
|
||||
content.y + run.baseline + @max(1, run.size * 0.1),
|
||||
run.width,
|
||||
thickness,
|
||||
)),
|
||||
.fill = colorFill(color),
|
||||
});
|
||||
}
|
||||
if (strikethrough_ordinal) |decoration_id_ordinal| {
|
||||
try builder.fillRect(.{
|
||||
.id = textSpanDecorationCommandId(widget.id, decoration_id_ordinal),
|
||||
.rect = pixelSnapGeometryRect(tokens, geometry.RectF.init(
|
||||
content.x + run.x,
|
||||
content.y + run.baseline - run.size * 0.3,
|
||||
run.width,
|
||||
thickness,
|
||||
)),
|
||||
.fill = colorFill(color),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (span.strikethrough) {
|
||||
try builder.fillRect(.{
|
||||
.id = textSpanDecorationCommandId(widget.id, decoration_ordinal),
|
||||
.rect = pixelSnapGeometryRect(tokens, geometry.RectF.init(
|
||||
content.x + run.x,
|
||||
content.y + run.baseline - run.size * 0.3,
|
||||
run.width,
|
||||
thickness,
|
||||
)),
|
||||
.fill = colorFill(color),
|
||||
});
|
||||
decoration_ordinal += 1;
|
||||
|
||||
const next_first_line = page_first_line +| lines_per_page;
|
||||
if (next_first_line <= page_first_line or
|
||||
next_first_line > last_visible_line or
|
||||
next_first_line >= layout.line_count)
|
||||
{
|
||||
break;
|
||||
}
|
||||
page_first_line = next_first_line;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1603,10 +1776,10 @@ fn emitVisibleCodeTextSpansWidget(
|
||||
visible_bounds: geometry.RectF,
|
||||
paint: CodeTextPaint,
|
||||
) Error!void {
|
||||
if (widget.code_editor and widget.text_no_wrap) {
|
||||
if (widget.runtime_flags.code_editor and widget.text_no_wrap) {
|
||||
return emitVisibleEditableCodeLines(builder, widget, tokens, visible_bounds, paint);
|
||||
}
|
||||
if (widget.code_editor) {
|
||||
if (widget.runtime_flags.code_editor) {
|
||||
return emitVisibleWrappedEditableCodeLines(builder, widget, tokens, visible_bounds, paint);
|
||||
}
|
||||
const spans = widget.spans;
|
||||
@@ -2226,7 +2399,7 @@ fn emitCodeLineDecorations(
|
||||
while (line_start <= widget.text.len) : (logical_line += 1) {
|
||||
// A terminal newline closes the preceding painted line; the span
|
||||
// breaker intentionally does not reserve another empty visual line.
|
||||
if (line_start == widget.text.len and widget.text.len > 0 and !widget.code_editor) break;
|
||||
if (line_start == widget.text.len and widget.text.len > 0 and !widget.runtime_flags.code_editor) break;
|
||||
const newline = std.mem.indexOfScalarPos(u8, widget.text, line_start, '\n');
|
||||
const line_end = newline orelse widget.text.len;
|
||||
const line = widget.text[line_start..line_end];
|
||||
|
||||
@@ -2796,6 +2796,83 @@ test "drag landing layout motion escapes its scroll lane clip" {
|
||||
try std.testing.expect(saw_landing_motion);
|
||||
}
|
||||
|
||||
test "rich text remains visible in floating drag and landing passes" {
|
||||
const source = "Lifted rich text";
|
||||
const spans = [_]canvas.TextSpan{.{ .text = source }};
|
||||
const root_frame = geometry.RectF.init(0, 0, 320, 140);
|
||||
const scroll_frame = geometry.RectF.init(10, 10, 140, 60);
|
||||
const card_frame = geometry.RectF.init(12, 80, 120, 36);
|
||||
const text_frame = geometry.RectF.init(18, 86, 108, 24);
|
||||
const nodes = [_]WidgetLayoutNode{
|
||||
.{
|
||||
.widget = .{ .id = 1, .kind = .panel, .frame = root_frame },
|
||||
.frame = root_frame,
|
||||
.depth = 0,
|
||||
},
|
||||
.{
|
||||
.widget = .{ .id = 2, .kind = .scroll_view, .frame = scroll_frame },
|
||||
.frame = scroll_frame,
|
||||
.depth = 1,
|
||||
.parent_index = 0,
|
||||
},
|
||||
.{
|
||||
.widget = .{
|
||||
.id = 3,
|
||||
.kind = .row,
|
||||
.frame = card_frame,
|
||||
.style = .{ .background = Color.rgb8(240, 240, 240), .radius = 6 },
|
||||
},
|
||||
.frame = card_frame,
|
||||
.depth = 2,
|
||||
.parent_index = 1,
|
||||
},
|
||||
.{
|
||||
.widget = .{
|
||||
.id = 4,
|
||||
.kind = .text,
|
||||
.frame = text_frame,
|
||||
.text = source,
|
||||
.spans = &spans,
|
||||
},
|
||||
.frame = text_frame,
|
||||
.depth = 3,
|
||||
.parent_index = 2,
|
||||
},
|
||||
};
|
||||
const layout = WidgetLayoutTree{ .nodes = &nodes };
|
||||
|
||||
var preview_commands: [64]CanvasCommand = undefined;
|
||||
var preview_builder = Builder.init(&preview_commands);
|
||||
try layout.emitDisplayListWithState(&preview_builder, .{}, .{
|
||||
.drag_preview_id = 3,
|
||||
.drag_preview_origin = geometry.PointF.init(card_frame.x, card_frame.y),
|
||||
.drag_preview_offset = geometry.OffsetF.init(0, -60),
|
||||
});
|
||||
|
||||
var preview_has_text = false;
|
||||
for (preview_builder.displayList().commands) |command| switch (command) {
|
||||
.draw_text => |draw| preview_has_text = preview_has_text or std.mem.eql(u8, source, draw.text),
|
||||
else => {},
|
||||
};
|
||||
try std.testing.expect(preview_has_text);
|
||||
|
||||
const layout_motions = [_]WidgetLayoutMotion{.{
|
||||
.id = 3,
|
||||
.offset = geometry.OffsetF.init(0, -60),
|
||||
.escape_ancestor_clips = true,
|
||||
}};
|
||||
var landing_commands: [64]CanvasCommand = undefined;
|
||||
var landing_builder = Builder.init(&landing_commands);
|
||||
try layout.emitDisplayListWithState(&landing_builder, .{}, .{ .layout_motions = &layout_motions });
|
||||
|
||||
var landing_has_text = false;
|
||||
for (landing_builder.displayList().commands) |command| switch (command) {
|
||||
.draw_text => |draw| landing_has_text = landing_has_text or std.mem.eql(u8, source, draw.text),
|
||||
else => {},
|
||||
};
|
||||
try std.testing.expect(landing_has_text);
|
||||
}
|
||||
|
||||
test "input-group wears the focus ring for its focused descendant" {
|
||||
const tokens = DesignTokens{};
|
||||
const group_children = [_]Widget{
|
||||
|
||||
@@ -191,14 +191,14 @@ fn widgetTextInputLineHeight(text_size: f32) f32 {
|
||||
}
|
||||
|
||||
fn widgetTextInputWrap(widget: Widget, line_height: f32) TextWrap {
|
||||
if (widget.code_editor) return if (widget.text_no_wrap) .none else .word;
|
||||
if (widget.runtime_flags.code_editor) return if (widget.text_no_wrap) .none else .word;
|
||||
if (widget.kind == .textarea) return .word;
|
||||
if (widget.kind == .text_field and widget.frame.height >= line_height * 2.25) return .word;
|
||||
return .none;
|
||||
}
|
||||
|
||||
fn widgetTextInputVerticalInset(widget: Widget, tokens: DesignTokens, text_size: f32, options: TextLayoutOptions) f32 {
|
||||
if (widget.code_editor) return 0;
|
||||
if (widget.runtime_flags.code_editor) return 0;
|
||||
if (options.wrap != .none) return widget_metrics.widgetControlInset(widget, tokens, tokens.spacing.sm);
|
||||
return @max(0, (widget.frame.height - widgetTextInputLineHeight(text_size)) * 0.5);
|
||||
}
|
||||
@@ -220,7 +220,7 @@ const text_input_caret_reserve: f32 = 1;
|
||||
/// unscrolled field always has.
|
||||
fn widgetTextInputHorizontalScrollOffset(widget: Widget, tokens: DesignTokens, text_size: f32, text_inset: f32, options: TextLayoutOptions) f32 {
|
||||
if (options.wrap != .none) return 0;
|
||||
const offset = if (widget.code_editor) widget.value_x else widget.value;
|
||||
const offset = if (widget.runtime_flags.code_editor) widget.value_x else widget.value;
|
||||
return std.math.clamp(offset, 0, widgetTextInputMaxHorizontalScrollOffset(widget, tokens, text_size, text_inset, options));
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ fn widgetTextInputMaxHorizontalScrollOffset(widget: Widget, tokens: DesignTokens
|
||||
// paints — so a value holding a line break scrolls by the same
|
||||
// single-line extent it draws.
|
||||
const font_id = widgetTextInputFontId(widget, tokens);
|
||||
const text_width = if (widget.code_editor)
|
||||
const text_width = if (widget.runtime_flags.code_editor)
|
||||
if (codeContentWidthCacheCurrent(widget, font_id, text_size))
|
||||
widget.code_content_width
|
||||
else
|
||||
@@ -243,7 +243,7 @@ fn widgetTextInputMaxHorizontalScrollOffset(widget: Widget, tokens: DesignTokens
|
||||
}
|
||||
|
||||
pub fn widgetTextInputOrigin(widget: Widget, tokens: DesignTokens, text_size: f32, inset: f32, options: TextLayoutOptions) geometry.PointF {
|
||||
if (widget.code_editor) {
|
||||
if (widget.runtime_flags.code_editor) {
|
||||
return geometry.PointF.init(
|
||||
widget.frame.x + inset - widgetTextInputHorizontalScrollOffset(widget, tokens, text_size, inset, options),
|
||||
widget.frame.y + text_size - widgetTextInputScrollOffset(widget, tokens, text_size, inset, options),
|
||||
@@ -365,7 +365,7 @@ pub fn textInputCaretVisibleScrollOffsetForWidget(widget: Widget, tokens: Design
|
||||
// selection rects measure with — the PRESENTED bytes (byte offsets
|
||||
// are interchangeable: the presentation substitutes 1:1).
|
||||
const caret_offset = snapTextOffset(widget.text, selection.focus);
|
||||
const caret_line_start = if (widget.code_editor)
|
||||
const caret_line_start = if (widget.runtime_flags.code_editor)
|
||||
(std.mem.lastIndexOfScalar(u8, widget.text[0..caret_offset], '\n') orelse 0) +
|
||||
@as(usize, @intFromBool(std.mem.lastIndexOfScalar(u8, widget.text[0..caret_offset], '\n') != null))
|
||||
else
|
||||
@@ -464,7 +464,7 @@ pub fn widgetTextInputDrawText(
|
||||
}
|
||||
|
||||
pub fn widgetTextInputInset(widget: Widget, tokens: DesignTokens) f32 {
|
||||
if (widget.code_editor) return widget_metrics.widgetCodeLineNumberGutterWidth(widget, tokens);
|
||||
if (widget.runtime_flags.code_editor) return widget_metrics.widgetCodeLineNumberGutterWidth(widget, tokens);
|
||||
const text_size = widgetTextInputSize(widget, tokens);
|
||||
return switch (widget.kind) {
|
||||
.search_field, .combobox => widget_metrics.widgetControlInset(widget, tokens, tokens.spacing.md) + @max(widget_metrics.widgetSizedDensityValue(widget, tokens, 8), text_size - 2) + widget_metrics.widgetControlInset(widget, tokens, tokens.spacing.sm),
|
||||
@@ -473,7 +473,7 @@ pub fn widgetTextInputInset(widget: Widget, tokens: DesignTokens) f32 {
|
||||
}
|
||||
|
||||
fn widgetTextInputFontId(widget: Widget, tokens: DesignTokens) FontId {
|
||||
return if (widget.code_editor) tokens.typography.mono_font_id else tokens.typography.font_id;
|
||||
return if (widget.runtime_flags.code_editor) tokens.typography.mono_font_id else tokens.typography.font_id;
|
||||
}
|
||||
|
||||
fn codeContentWidthCacheCurrent(widget: Widget, font_id: FontId, text_size: f32) bool {
|
||||
@@ -489,7 +489,7 @@ fn codeContentWidthCacheCurrent(widget: Widget, font_id: FontId, text_size: f32)
|
||||
/// Runtime reconciliation carries this cache across unchanged app rebuilds;
|
||||
/// edits invalidate it before caret scrolling recomputes the new document.
|
||||
pub fn cacheTextInputContentWidthForWidget(widget: *Widget, tokens: DesignTokens) void {
|
||||
if (widget.kind != .textarea or !widget.code_editor or !widget.text_no_wrap) return;
|
||||
if (widget.kind != .textarea or !widget.runtime_flags.code_editor or !widget.text_no_wrap) return;
|
||||
if (widget.hasCodeDiff()) return;
|
||||
const text_size = widgetTextInputSize(widget.*, tokens);
|
||||
const font_id = widgetTextInputFontId(widget.*, tokens);
|
||||
@@ -578,7 +578,7 @@ fn widgetTextInputTrailingInset(widget: Widget, text_size: f32, inset: f32) f32
|
||||
// field padding. Its source viewport runs all the way to the trailing
|
||||
// edge; mirroring the gutter here invents horizontal overflow for lines
|
||||
// that visibly fit beside the numbers.
|
||||
if (widget.code_editor) return 0;
|
||||
if (widget.runtime_flags.code_editor) return 0;
|
||||
if (widget.kind == .combobox) return inset + @max(8, text_size - 4);
|
||||
// A search field holding text reserves the trailing slot for the
|
||||
// built-in clear affordance so the text never runs under the x.
|
||||
|
||||
@@ -128,7 +128,7 @@ pub fn widgetScrollsAxis(widget: Widget, axis: token_model.ScrollAxis) bool {
|
||||
return switch (axis) {
|
||||
.vertical => widget.kind != .scroll_view or widget.scroll_axes.scrollsVertically() or widget.layout.virtualized,
|
||||
.horizontal => (widget.kind == .scroll_view and widget.scroll_axes.scrollsHorizontally() and !widget.layout.virtualized) or
|
||||
(widget.kind == .textarea and widget.code_editor and widget.text_no_wrap),
|
||||
(widget.kind == .textarea and widget.runtime_flags.code_editor and widget.text_no_wrap),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -264,6 +264,18 @@ pub const WidgetState = struct {
|
||||
invalid: bool = false,
|
||||
};
|
||||
|
||||
/// Engine-owned widget markers share one byte so adding runtime policy does
|
||||
/// not expand every retained `Widget`. Builder-facing behavior remains on
|
||||
/// ordinary named fields; these bits are stamped only by engine code.
|
||||
pub const WidgetRuntimeFlags = packed struct(u8) {
|
||||
/// `Ui.code` stamped this textarea as the editor surface.
|
||||
code_editor: bool = false,
|
||||
/// The runtime installed an OS-native scroll driver; engine-drawn
|
||||
/// scrollbar and kinetic physics stand down for this scroll view.
|
||||
native_scroll: bool = false,
|
||||
_reserved: u6 = 0,
|
||||
};
|
||||
|
||||
/// Two 128-line masks for code-only diff presentation. `Widget` packs them
|
||||
/// into fields dormant on decorated code so ordinary widgets do not grow.
|
||||
pub const CodeDiffLines = struct {
|
||||
@@ -947,7 +959,14 @@ pub const Widget = struct {
|
||||
/// selection, and accessibility paths all apply; this bit only swaps
|
||||
/// the textarea's visual/geometry policy to bare monospace highlighted
|
||||
/// code (no control fill, border, radius, focus ring, or inset).
|
||||
code_editor: bool = false,
|
||||
runtime_flags: WidgetRuntimeFlags = .{},
|
||||
/// Textarea Enter policy (`ElementOptions.submit_on_enter` / markup
|
||||
/// `submit-on-enter`): plain Enter submits instead of editing, while
|
||||
/// Shift+Enter remains a newline. False keeps the ordinary multiline
|
||||
/// contract. Ignored by non-textarea kinds. The engine-only booleans in
|
||||
/// `runtime_flags` share a byte so adding this policy does not grow every
|
||||
/// retained widget.
|
||||
submit_on_enter: bool = false,
|
||||
/// Syntax grammar for an editable `Ui.code` surface. Editable code
|
||||
/// retains one plain source span and tokenizes only visible logical
|
||||
/// lines during paint, so large documents do not consume the view's
|
||||
@@ -1033,10 +1052,6 @@ pub const Widget = struct {
|
||||
semantics: WidgetSemantics = .{},
|
||||
/// App-declared native context menu for this widget (empty = none).
|
||||
context_menu: []const WidgetContextMenuItem = &.{},
|
||||
/// True when the runtime installed a native scroll driver for this
|
||||
/// `.scroll_view`: the engine's drawn scrollbar and kinetic physics
|
||||
/// stand down — the OS scroller owns feel and the overlay scroller.
|
||||
native_scroll: bool = false,
|
||||
/// Per-region edge behavior for scroll containers (`overscroll:` in
|
||||
/// the builder, `overscroll=` in markup): `.default` follows the
|
||||
/// `ScrollPhysics.overscroll` token (off unless a theme flips it),
|
||||
@@ -1618,3 +1633,12 @@ fn mergeLayoutDefaults(explicit: WidgetLayoutStyle, defaults: WidgetLayoutStyle)
|
||||
if (explicit.min_size.width == 0 and explicit.min_size.height == 0) merged.min_size = defaults.min_size;
|
||||
return merged;
|
||||
}
|
||||
|
||||
test "Widget keeps the retained hot-path footprint after textarea policy flags" {
|
||||
// One layout tree holds thousands of Widgets by value. On the 64-bit
|
||||
// targets that run the renderer, 776 bytes is the reviewed footprint;
|
||||
// packing engine-only markers keeps the new textarea policy within it.
|
||||
if (@sizeOf(usize) == 8) {
|
||||
try std.testing.expectEqual(@as(usize, 776), @sizeOf(Widget));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,6 +508,7 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type {
|
||||
self.views[view_index].recordGpuSurfaceInputTimestamp(automationInputTimestampNs());
|
||||
try CanvasWidgetEventMethods().invalidateForCanvasWidgetRenderStateChange(self, view_index, previous_state, self.views[view_index].canvasWidgetRenderState());
|
||||
}
|
||||
_ = try self.views[view_index].ensureCanvasWidgetFocusedTextCaret();
|
||||
}
|
||||
|
||||
pub fn dispatchAutomationWidgetKey(self: *Runtime, app: runtime_api.App(Runtime), view_index: usize, id: canvas.ObjectId, key: []const u8) anyerror!void {
|
||||
|
||||
@@ -2862,7 +2862,7 @@ fn canvasWidgetTerminalOwnsTabInput(layout: canvas.WidgetLayoutTree, target: can
|
||||
fn canvasWidgetCodeEditorOwnsTabInput(layout: canvas.WidgetLayoutTree, target: canvas.WidgetFocusTarget) bool {
|
||||
if (target.kind != .textarea or target.index >= layout.nodes.len) return false;
|
||||
const widget = layout.nodes[target.index].widget;
|
||||
return widget.code_editor and !widget.state.disabled;
|
||||
return widget.runtime_flags.code_editor and !widget.state.disabled;
|
||||
}
|
||||
|
||||
fn canvasDirtyRegionForView(view_frame: geometry.RectF, local_dirty: geometry.RectF) ?geometry.RectF {
|
||||
|
||||
@@ -253,6 +253,68 @@ pub fn restoreCanvasWidgetLayoutScrollOffsets(
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp a fresh or genuinely PROGRAMMATIC source offset before a native
|
||||
/// scroll driver adopts the layout. Native drivers otherwise keep their raw
|
||||
/// runtime offset so an Elm-style rebuild cannot interrupt rubber-band
|
||||
/// overscroll. A source value that exactly echoes the retained runtime value
|
||||
/// is the same user-driven state, not a programmatic move, and stays exempt.
|
||||
///
|
||||
/// This pass is what makes an intentionally out-of-range source value useful
|
||||
/// as "scroll to the end": descendants are first laid out at that source
|
||||
/// value, then translated back to the content-range clamp before either the
|
||||
/// retained tree or the native driver sees them.
|
||||
fn clampCanvasWidgetLayoutProgrammaticScrollOffsets(
|
||||
nodes: []canvas.WidgetLayoutNode,
|
||||
source: canvas.WidgetLayoutTree,
|
||||
previous_runtime_offsets: []const CanvasWidgetSourceScrollEntry,
|
||||
previous_source_offsets: []const CanvasWidgetSourceScrollEntry,
|
||||
) void {
|
||||
for (nodes, 0..) |node, index| {
|
||||
if (node.widget.kind != .scroll_view or node.widget.id == 0) continue;
|
||||
if (node.widget.layout.virtualized and !canvas.widgetVirtualRuntimeScrolled(node.widget)) continue;
|
||||
|
||||
const source_node = source.findById(node.widget.id) orelse continue;
|
||||
const previous_runtime = canvasWidgetSourceScrollEntryById(previous_runtime_offsets, node.widget.id);
|
||||
const previous_source = canvasWidgetSourceScrollEntryById(previous_source_offsets, node.widget.id);
|
||||
const fresh = previous_source == null;
|
||||
const source_moved_y = fresh or source_node.widget.value != previous_source.?.value;
|
||||
const source_moved_x = fresh or source_node.widget.value_x != previous_source.?.value_x;
|
||||
const runtime_echo_y = previous_runtime != null and source_node.widget.value == previous_runtime.?.value;
|
||||
const runtime_echo_x = previous_runtime != null and source_node.widget.value_x == previous_runtime.?.value_x;
|
||||
const clamp_y = source_moved_y and !runtime_echo_y;
|
||||
const clamp_x = source_moved_x and !runtime_echo_x;
|
||||
if (!clamp_y and !clamp_x) continue;
|
||||
|
||||
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
|
||||
if (viewport.isEmpty()) continue;
|
||||
|
||||
const current_y = node.widget.value;
|
||||
const current_x = node.widget.value_x;
|
||||
const next_y = if (clamp_y)
|
||||
if (canvas.widgetScrollsAxis(node.widget, .vertical))
|
||||
std.math.clamp(@max(0, current_y), 0, @max(0, canvasWidgetLayoutScrollContentExtent(nodes, index, viewport) - viewport.height))
|
||||
else
|
||||
0
|
||||
else
|
||||
current_y;
|
||||
const next_x = if (clamp_x)
|
||||
if (canvas.widgetScrollsAxis(node.widget, .horizontal))
|
||||
std.math.clamp(@max(0, current_x), 0, @max(0, canvasWidgetLayoutScrollContentExtentX(nodes, index, viewport) - viewport.width))
|
||||
else
|
||||
0
|
||||
else
|
||||
current_x;
|
||||
if (next_y == current_y and next_x == current_x) continue;
|
||||
|
||||
nodes[index].widget.value = next_y;
|
||||
nodes[index].widget.value_x = next_x;
|
||||
translateCanvasWidgetLayoutScrollDescendants(nodes, index, .{
|
||||
.dx = if (canvas.widgetScrollsAxis(node.widget, .horizontal)) -(next_x - current_x) else 0,
|
||||
.dy = if (canvas.widgetScrollsAxis(node.widget, .vertical)) -(next_y - current_y) else 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn previousLayoutHasSelectedTab(previous: canvas.WidgetLayoutTree, id: canvas.ObjectId) bool {
|
||||
for (previous.nodes) |node| {
|
||||
if (node.widget.id == id) return node.widget.semantics.role == .tab and node.widget.state.selected;
|
||||
@@ -878,7 +940,7 @@ pub fn canvasWidgetLayoutNodeWithTextReconcileState(
|
||||
// right after, so a resized or re-texted field never keeps a
|
||||
// stale offset).
|
||||
if (canvasWidgetEditableTextKind(copy.widget.kind)) copy.widget.value = entry.value;
|
||||
if (copy.widget.code_editor) {
|
||||
if (copy.widget.runtime_flags.code_editor) {
|
||||
copy.widget.value_x = entry.value_x;
|
||||
if (!copy.widget.hasCodeDiff()) {
|
||||
copy.widget.code_content_width = entry.code_content_width;
|
||||
@@ -1036,6 +1098,12 @@ pub fn canvasWidgetLayoutTreeWithRuntimeReconcileState(
|
||||
// the caller AFTER native scroll drivers are stamped — a rebuild
|
||||
// mid-rubber-band must not clamp an offset the OS scroller owns.
|
||||
restoreCanvasWidgetLayoutScrollOffsets(staged_nodes, previous_runtime_offsets, previous_source_scroll_entries);
|
||||
clampCanvasWidgetLayoutProgrammaticScrollOffsets(
|
||||
staged_nodes,
|
||||
next,
|
||||
previous_runtime_offsets,
|
||||
previous_source_scroll_entries,
|
||||
);
|
||||
revealNewlySelectedTabs(previous, staged_nodes);
|
||||
|
||||
const index_scratch = canvas_widget_reconcile_index_scratch.get();
|
||||
@@ -1101,7 +1169,7 @@ pub fn clampCanvasWidgetLayoutScrollOffsets(nodes: []canvas.WidgetLayoutNode, st
|
||||
// must behave the same on every host, or a source still
|
||||
// echoing the old offset would resurrect it on re-grant only
|
||||
// where drivers run.
|
||||
if (node.widget.native_scroll) {
|
||||
if (node.widget.runtime_flags.native_scroll) {
|
||||
const pin_y = !canvas.widgetScrollsAxis(node.widget, .vertical) and node.widget.value != 0;
|
||||
const pin_x = !canvas.widgetScrollsAxis(node.widget, .horizontal) and node.widget.value_x != 0;
|
||||
if (pin_y) nodes[index].widget.value = 0;
|
||||
@@ -1173,7 +1241,7 @@ pub fn clampCanvasWidgetLayoutTextOffsets(nodes: []canvas.WidgetLayoutNode, toke
|
||||
if (node.widget.kind == .textarea) {
|
||||
canvas.cacheTextInputContentWidthForWidget(&node.widget, tokens);
|
||||
node.widget.value = canvas.clampedTextInputScrollOffsetForWidget(node.widget, tokens, node.widget.value);
|
||||
node.widget.value_x = if (node.widget.code_editor)
|
||||
node.widget.value_x = if (node.widget.runtime_flags.code_editor)
|
||||
canvas.clampedTextInputHorizontalScrollOffsetForWidget(node.widget, tokens, node.widget.value_x)
|
||||
else
|
||||
0;
|
||||
|
||||
@@ -93,7 +93,7 @@ test "layout install publishes native scroll drivers and suppresses engine scrol
|
||||
// The retained scroll node is marked natively driven and the engine
|
||||
// scrollbar (widget part slots 2 and 3) is not emitted.
|
||||
const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expect(retained.nodes[0].widget.native_scroll);
|
||||
try std.testing.expect(retained.nodes[0].widget.runtime_flags.native_scroll);
|
||||
_ = try harness.runtime.emitCanvasWidgetDisplayList(1, "canvas", .{});
|
||||
const display_list = try harness.runtime.canvasDisplayList(1, "canvas");
|
||||
for (display_list.commands) |command| {
|
||||
@@ -113,6 +113,39 @@ test "layout install publishes native scroll drivers and suppresses engine scrol
|
||||
try std.testing.expectEqual(@as(f32, 120.0), snapshot.widgets[0].scroll.content_extent);
|
||||
}
|
||||
|
||||
test "programmatic scroll offsets clamp before native drivers adopt them" {
|
||||
const harness = try TestHarness().create(std.testing.allocator, .{});
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
harness.null_platform.gpu_surfaces = true;
|
||||
harness.null_platform.gpu_surface_scroll_drivers = true;
|
||||
var app_state: PassiveApp = .{};
|
||||
try harness.start(app_state.app());
|
||||
|
||||
_ = try harness.runtime.createView(.{
|
||||
.window_id = 1,
|
||||
.label = "canvas",
|
||||
.kind = .gpu_surface,
|
||||
.frame = geometry.RectF.init(10, 20, 180, 72),
|
||||
});
|
||||
|
||||
// Apps deliberately use an out-of-range source value for "scroll to
|
||||
// end". Clamp that intent against the laid-out content BEFORE the
|
||||
// native driver or retained frames can expose the raw displacement.
|
||||
var nodes: [5]canvas.WidgetLayoutNode = undefined;
|
||||
const layout = try scrollFixtureLayout(&nodes, 1_000_000);
|
||||
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
|
||||
|
||||
const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expectEqual(@as(f32, 48), retained.nodes[0].widget.value);
|
||||
try std.testing.expectEqualDeep(geometry.RectF.init(0, -48, 180, 32), retained.nodes[1].frame);
|
||||
try std.testing.expectEqualDeep(geometry.RectF.init(0, 40, 180, 32), retained.nodes[3].frame);
|
||||
|
||||
const drivers = harness.null_platform.scrollDrivers();
|
||||
try std.testing.expectEqual(@as(usize, 1), drivers.len);
|
||||
try std.testing.expectEqual(@as(f32, 48), drivers[0].offset_y);
|
||||
try std.testing.expectEqual(@as(f32, 120), drivers[0].content_size.height);
|
||||
}
|
||||
|
||||
test "a region's rubber-band opt-in reaches its driver spec" {
|
||||
const harness = try TestHarness().create(std.testing.allocator, .{});
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
@@ -201,6 +234,16 @@ test "driver offsets scroll retained scroll views and pass through overscroll" {
|
||||
try std.testing.expectEqualDeep(geometry.RectF.init(0, 10, 180, 32), retained.nodes[1].frame);
|
||||
try std.testing.expect(!harness.runtime.views[0].canvasWidgetKineticScrollActive());
|
||||
|
||||
// A controlled model may immediately echo that driver offset through
|
||||
// on-scroll. It is still the live user bounce, not a programmatic
|
||||
// source move, so the rebuild must preserve it without clamping.
|
||||
var echo_nodes: [5]canvas.WidgetLayoutNode = undefined;
|
||||
const echo_layout = try scrollFixtureLayout(&echo_nodes, -10);
|
||||
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", echo_layout);
|
||||
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expectEqual(@as(f32, -10), retained.nodes[0].widget.value);
|
||||
try std.testing.expectEqualDeep(geometry.RectF.init(0, 10, 180, 32), retained.nodes[1].frame);
|
||||
|
||||
// The OS scroller settles the bounce and reports the rested offset.
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_scroll_driver = .{
|
||||
.window_id = 1,
|
||||
@@ -409,7 +452,7 @@ test "scroll drivers stay unpublished without platform support" {
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), harness.null_platform.scroll_driver_set_count);
|
||||
const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expect(!retained.nodes[0].widget.native_scroll);
|
||||
try std.testing.expect(!retained.nodes[0].widget.runtime_flags.native_scroll);
|
||||
|
||||
// Engine scrollbar still draws for engine-owned scrolling.
|
||||
_ = try harness.runtime.emitCanvasWidgetDisplayList(1, "canvas", .{});
|
||||
@@ -483,7 +526,7 @@ test "windowed virtual lists ride the native scroll driver with the full virtual
|
||||
// The retained region is natively driven: engine scrollbar and
|
||||
// engine physics stand down.
|
||||
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expect(retained.nodes[0].widget.native_scroll);
|
||||
try std.testing.expect(retained.nodes[0].widget.runtime_flags.native_scroll);
|
||||
|
||||
// A driver-reported offset scrolls the window (the optimistic echo
|
||||
// translates the built rows; the app's rebuild re-windows).
|
||||
@@ -505,7 +548,7 @@ test "windowed virtual lists ride the native scroll driver with the full virtual
|
||||
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", legacy_layout);
|
||||
try std.testing.expectEqual(@as(usize, 0), harness.null_platform.scrollDrivers().len);
|
||||
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expect(!retained.nodes[0].widget.native_scroll);
|
||||
try std.testing.expect(!retained.nodes[0].widget.runtime_flags.native_scroll);
|
||||
}
|
||||
|
||||
test "a rebuild mid-overscroll keeps the driver's offset and pushes nothing" {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! momentum, overlay scrollbars, and rubber-band for regions that opt
|
||||
//! into it — while the engine keeps rendering the content. The runtime:
|
||||
//!
|
||||
//! - stamps `widget.native_scroll` on every non-virtualized `.scroll_view`
|
||||
//! - stamps `widget.runtime_flags.native_scroll` on every non-virtualized `.scroll_view`
|
||||
//! (and every RUNTIME-SCROLLED virtual list — a virtualized scroll_view
|
||||
//! with a declared item count, whose driver content size is the full
|
||||
//! virtual extent) so engine scrollbars and engine kinetic physics
|
||||
@@ -52,7 +52,7 @@ pub fn RuntimeCanvasWidgetScrollDrivers(comptime Runtime: type) type {
|
||||
pub fn stampCanvasWidgetNativeScroll(self: *const Runtime, nodes: []canvas.WidgetLayoutNode) void {
|
||||
if (!canvasWidgetScrollDriversSupported(self)) return;
|
||||
for (nodes) |*node| {
|
||||
if (canvasWidgetScrollDriverEligible(node.*)) node.widget.native_scroll = true;
|
||||
if (canvasWidgetScrollDriverEligible(node.*)) node.widget.runtime_flags.native_scroll = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ pub fn RuntimeCanvasWidgetScrollDrivers(comptime Runtime: type) type {
|
||||
}
|
||||
for (view.widget_layout_nodes[0..view.widget_layout_node_count], 0..) |*node, node_index| {
|
||||
if (!canvasWidgetScrollDriverEligible(node.*)) continue;
|
||||
node.widget.native_scroll = true;
|
||||
node.widget.runtime_flags.native_scroll = true;
|
||||
if (count >= drivers.len) continue;
|
||||
|
||||
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
|
||||
|
||||
@@ -701,6 +701,8 @@ test "runtime applies source-driven autofocus on the edge, never on the level" {
|
||||
try std.testing.expectEqual(@as(canvas.ObjectId, 7), harness.runtime.views[0].canvas_widget_focused_id);
|
||||
try std.testing.expectEqual(@as(canvas.ObjectId, 7), harness.runtime.views[0].canvas_widget_focus_visible_id);
|
||||
try std.testing.expect(harness.runtime.views[0].focused);
|
||||
const focused_layout = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(0), focused_layout.findById(7).?.widget.text_selection.?);
|
||||
|
||||
// The user moves focus; re-applying the SAME layout (flag held true)
|
||||
// must not steal it back — edge-triggered, level-ignored.
|
||||
|
||||
@@ -32,7 +32,7 @@ fn canvasWidgetLayoutNeedsLargeTextStorage(layout: canvas.WidgetLayoutTree) bool
|
||||
var text_len: usize = 0;
|
||||
for (layout.nodes) |node| {
|
||||
const widget = node.widget;
|
||||
if (widget.code_editor) return true;
|
||||
if (widget.runtime_flags.code_editor) return true;
|
||||
text_len +|= widget.text.len +| widget.icon.len +| widget.command.len +| widget.semantics.label.len;
|
||||
for (widget.spans) |span| text_len +|= span.text.len +| span.link.len;
|
||||
for (widget.context_menu) |item| text_len +|= item.label.len;
|
||||
@@ -185,6 +185,11 @@ pub fn RuntimeCanvasWidgetState(comptime Runtime: type) type {
|
||||
try self.views[index].copyCanvasWidgetSourceText(layout);
|
||||
self.views[index].copyCanvasWidgetSourceScroll(layout);
|
||||
self.views[index].copyCanvasWidgetSourceControls(layout);
|
||||
// A controlled text replacement (the common submit-and-clear
|
||||
// composer flow) drops stale selection state while retaining
|
||||
// logical focus. Re-establish the insertion point before this
|
||||
// rebuild emits so the caret never disappears until typing.
|
||||
_ = try self.views[index].ensureCanvasWidgetFocusedTextCaret();
|
||||
// Push the reconciled regions (frames, content extents,
|
||||
// diverged offsets) to the native scroll drivers.
|
||||
ScrollDriverMethods(Runtime).syncCanvasWidgetScrollDriversForView(self, index);
|
||||
|
||||
@@ -109,7 +109,7 @@ test "closing an earlier view transfers a later view's expanded text storage" {
|
||||
.kind = .textarea,
|
||||
.text = "expanded",
|
||||
.text_no_wrap = true,
|
||||
.code_editor = true,
|
||||
.runtime_flags = .{ .code_editor = true },
|
||||
};
|
||||
var editor_nodes: [1]canvas.WidgetLayoutNode = undefined;
|
||||
const editor_layout = try canvas.layoutWidgetTree(editor, geometry.RectF.init(0, 0, 240, 160), &editor_nodes);
|
||||
@@ -2298,6 +2298,115 @@ test "plain Enter inserts a newline in a canvas textarea; chorded Enter never ed
|
||||
} });
|
||||
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expectEqualStrings("First\n", retained.nodes[1].widget.text);
|
||||
|
||||
// The chat-composer policy leaves plain Enter for the UI submit
|
||||
// handler, so retained text does not receive a newline. Shift+Enter
|
||||
// remains an edit and inserts one exactly as the default textarea
|
||||
// does.
|
||||
const prompt = canvas.Widget{
|
||||
.id = 2,
|
||||
.kind = .textarea,
|
||||
.frame = geometry.RectF.init(12, 16, 180, 84),
|
||||
.text = "Prompt",
|
||||
.submit_on_enter = true,
|
||||
.semantics = .{ .label = "Message" },
|
||||
};
|
||||
var prompt_nodes: [2]canvas.WidgetLayoutNode = undefined;
|
||||
const prompt_layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{prompt} }, geometry.RectF.init(0, 0, 260, 160), &prompt_nodes);
|
||||
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", prompt_layout);
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = "canvas",
|
||||
.kind = .key_down,
|
||||
.key = "enter",
|
||||
} });
|
||||
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expectEqualStrings("Prompt", retained.nodes[1].widget.text);
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = "canvas",
|
||||
.kind = .key_down,
|
||||
.key = "enter",
|
||||
.modifiers = .{ .shift = true },
|
||||
} });
|
||||
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expectEqualStrings("Prompt\n", retained.nodes[1].widget.text);
|
||||
}
|
||||
|
||||
test "focused textarea keeps a visible caret when controlled source clears after submit" {
|
||||
const TestApp = struct {
|
||||
fn app(self: *@This()) App {
|
||||
return .{ .context = self, .name = "gpu-widget-textarea-submit-clear", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
|
||||
}
|
||||
};
|
||||
|
||||
const harness = try TestHarness().create(std.testing.allocator, .{});
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
harness.null_platform.gpu_surfaces = true;
|
||||
var app_state: TestApp = .{};
|
||||
const app = app_state.app();
|
||||
try harness.start(app);
|
||||
|
||||
_ = try harness.runtime.createView(.{
|
||||
.window_id = 1,
|
||||
.label = "canvas",
|
||||
.kind = .gpu_surface,
|
||||
.frame = geometry.RectF.init(0, 0, 260, 160),
|
||||
});
|
||||
|
||||
const prompt = canvas.Widget{
|
||||
.id = 2,
|
||||
.kind = .textarea,
|
||||
.frame = geometry.RectF.init(12, 16, 180, 84),
|
||||
.text = "Prompt",
|
||||
.submit_on_enter = true,
|
||||
.semantics = .{ .label = "Message" },
|
||||
};
|
||||
var prompt_nodes: [2]canvas.WidgetLayoutNode = undefined;
|
||||
const prompt_layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{prompt} }, geometry.RectF.init(0, 0, 260, 160), &prompt_nodes);
|
||||
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", prompt_layout);
|
||||
_ = try harness.runtime.emitCanvasWidgetDisplayList(1, "canvas", .{});
|
||||
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = "canvas",
|
||||
.kind = .pointer_down,
|
||||
.x = 100,
|
||||
.y = 30,
|
||||
} });
|
||||
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = "canvas",
|
||||
.kind = .key_down,
|
||||
.key = "enter",
|
||||
} });
|
||||
|
||||
var cleared_prompt = prompt;
|
||||
cleared_prompt.text = "";
|
||||
var cleared_nodes: [2]canvas.WidgetLayoutNode = undefined;
|
||||
const cleared_layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{cleared_prompt} }, geometry.RectF.init(0, 0, 260, 160), &cleared_nodes);
|
||||
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", cleared_layout);
|
||||
|
||||
const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
|
||||
try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_focused_id);
|
||||
try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_focus_visible_id);
|
||||
try std.testing.expectEqualStrings("", retained.nodes[1].widget.text);
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(0), retained.nodes[1].widget.text_selection.?);
|
||||
try std.testing.expectEqualDeep(canvas.TextRange.init(0, 0), runtimeViewWidgetSemantics(&harness.runtime.views[0])[0].text_selection.?);
|
||||
|
||||
_ = try harness.runtime.emitCanvasWidgetDisplayList(1, "canvas", .{});
|
||||
const display_list = try harness.runtime.canvasDisplayList(1, "canvas");
|
||||
var saw_caret = false;
|
||||
for (display_list.commands) |command| {
|
||||
switch (command) {
|
||||
.fill_rect => |fill| {
|
||||
if (fill.id == testCanvasWidgetPartId(2, 6)) saw_caret = true;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
try std.testing.expect(saw_caret);
|
||||
try std.testing.expectEqual(testCanvasWidgetPartId(2, 6), harness.runtime.views[0].canvas_widget_caret_blink_id);
|
||||
}
|
||||
|
||||
test "Enter in a single-line input never inserts, even when the host stuffs a newline into the key event" {
|
||||
@@ -3906,7 +4015,7 @@ test "editable code owns plain Tab and stamps its inferred indentation edit" {
|
||||
.{
|
||||
.id = 2,
|
||||
.kind = .textarea,
|
||||
.code_editor = true,
|
||||
.runtime_flags = .{ .code_editor = true },
|
||||
.text_no_wrap = true,
|
||||
.frame = geometry.RectF.init(12, 16, 220, 100),
|
||||
.text = source,
|
||||
@@ -4620,7 +4729,7 @@ test "runtime scrolls editable no-wrap code horizontally and retains both axes"
|
||||
const editor = canvas.Widget{
|
||||
.id = 2,
|
||||
.kind = .textarea,
|
||||
.code_editor = true,
|
||||
.runtime_flags = .{ .code_editor = true },
|
||||
.code_line_number_digits = 2,
|
||||
.text_no_wrap = true,
|
||||
.frame = geometry.RectF.init(12, 16, 120, 72),
|
||||
|
||||
+44
-2
@@ -10294,6 +10294,33 @@ pub fn Effects(comptime Msg: type) type {
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one synthetic line-mode result with the same loss metadata a
|
||||
/// real executor can attach. This is the exact fake/replay seam for
|
||||
/// testing consumers that must reject cut or dropped stream data;
|
||||
/// collect-mode spawns have no `EffectLine` results and are refused.
|
||||
pub fn feedLineWithMetadata(
|
||||
self: *Self,
|
||||
key: u64,
|
||||
bytes: []const u8,
|
||||
truncated: bool,
|
||||
dropped_before: u32,
|
||||
) error{ EffectNotFound, EffectQueueFull }!void {
|
||||
const slot_index = self.findActiveFakeSlot(key, .spawn) orelse blk: {
|
||||
const fetch_index = self.findActiveFakeSlot(key, .fetch) orelse return error.EffectNotFound;
|
||||
if (self.slots[fetch_index].fetch_response_mode != .stream) return error.EffectNotFound;
|
||||
break :blk fetch_index;
|
||||
};
|
||||
const slot = &self.slots[slot_index];
|
||||
if (slot.kind == .spawn and slot.output_mode == .collect) return error.EffectNotFound;
|
||||
|
||||
const prior_dropped = slot.dropped_pending;
|
||||
slot.dropped_pending +|= dropped_before;
|
||||
if (!self.produceLine(slot, @intCast(slot_index), slot.generation, bytes, truncated, true)) {
|
||||
slot.dropped_pending = prior_dropped;
|
||||
return error.EffectQueueFull;
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed synthetic stderr bytes to the fake `.collect` effect with
|
||||
/// `key`. Mirrors the real tail: only the last
|
||||
/// `max_effect_stderr_tail_bytes` are kept and earlier bytes are
|
||||
@@ -10388,6 +10415,21 @@ pub fn Effects(comptime Msg: type) type {
|
||||
/// `.connect_failed`, ...). Non-`.ok` outcomes carry no body,
|
||||
/// mirroring the real executor.
|
||||
pub fn feedResponseOutcome(self: *Self, key: u64, outcome: EffectFetchOutcome, status: u16, body: []const u8) error{EffectNotFound}!void {
|
||||
return self.feedResponseOutcomeWithMetadata(key, outcome, status, body, false, 0);
|
||||
}
|
||||
|
||||
/// Exact synthetic response feed, including loss metadata recorded on
|
||||
/// a real `EffectResponse`. The ordinary helper above derives body
|
||||
/// truncation and supplies zero additional loss.
|
||||
pub fn feedResponseOutcomeWithMetadata(
|
||||
self: *Self,
|
||||
key: u64,
|
||||
outcome: EffectFetchOutcome,
|
||||
status: u16,
|
||||
body: []const u8,
|
||||
truncated: bool,
|
||||
dropped_before: u32,
|
||||
) error{EffectNotFound}!void {
|
||||
const slot_index = self.findActiveFakeSlot(key, .fetch) orelse return error.EffectNotFound;
|
||||
const slot = &self.slots[slot_index];
|
||||
const buffer = slot.fetch_buffer orelse return error.EffectNotFound;
|
||||
@@ -10409,8 +10451,8 @@ pub fn Effects(comptime Msg: type) type {
|
||||
.generation = slot.generation,
|
||||
.key = slot.key,
|
||||
.line_len = @intCast(slot.body_len),
|
||||
.truncated = slot.fetch_truncated,
|
||||
.dropped_before = slot.dropped_pending,
|
||||
.truncated = slot.fetch_truncated or truncated,
|
||||
.dropped_before = slot.dropped_pending +| dropped_before,
|
||||
.status = status,
|
||||
.outcome = outcome,
|
||||
.response_fn = slot.on_response,
|
||||
|
||||
+176
-32
@@ -73,6 +73,15 @@
|
||||
//! ("truncated") rather than passing a silently cut
|
||||
//! body as ok. Wire timeout 0 means the engine
|
||||
//! default.
|
||||
//! fetch_stream -> `fx.fetch` (`.stream`) through the STREAM table;
|
||||
//! every complete response line routes the line arm,
|
||||
//! and the ONE terminal retires the entry: `.ok`
|
||||
//! routes the HTTP status through the one-number ok
|
||||
//! arm, every other outcome routes the err arm with
|
||||
//! its name. Wire timeout 0 and max-line 0 select the
|
||||
//! engine defaults. `cancel` is loud for a live fetch
|
||||
//! stream: it ends with err "cancelled", with no lines
|
||||
//! after the cancel.
|
||||
//! clip_write -> `fx.writeClipboard` fire-and-forget (`on_result`
|
||||
//! null): a refused or over-bound write is dropped by
|
||||
//! design — there is no route to report on. Engine
|
||||
@@ -513,7 +522,7 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
}
|
||||
};
|
||||
|
||||
/// One live spawn STREAM — the non-retiring entry kind: line
|
||||
/// One live spawn or fetch STREAM — the non-retiring entry kind: line
|
||||
/// results route through it repeatedly across dispatches, and
|
||||
/// only the exit terminal (or the engine's `.cancelled` end
|
||||
/// after a wire cancel) retires it. The table index IS the
|
||||
@@ -523,11 +532,17 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
used: bool = false,
|
||||
key_len: usize = 0,
|
||||
key: [max_wire_key_bytes]u8 = undefined,
|
||||
/// `spawn_no_line_tag` = lines dispatch nothing.
|
||||
/// `spawn_no_line_tag` = lines dispatch nothing (spawn only;
|
||||
/// fetch streams always carry a line route).
|
||||
line_tag: u8 = spawn_no_line_tag,
|
||||
exit_tag: u8 = 0,
|
||||
err_tag: u8 = 0,
|
||||
collect: bool = false,
|
||||
/// Fetch lines are data records, so a cut or dropped line makes
|
||||
/// the eventual success terminal unusable. Spawn line mode keeps
|
||||
/// its existing best-effort contract.
|
||||
fetch: bool = false,
|
||||
damaged: bool = false,
|
||||
|
||||
fn wireKey(entry: *const StreamEntry) []const u8 {
|
||||
return entry.key[0..entry.key_len];
|
||||
@@ -966,17 +981,27 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
headers[i] = .{ .name = name, .value = value };
|
||||
}
|
||||
const body = takeLongBytes(cmd, &at);
|
||||
fx.fetch(.{
|
||||
.key = effect_key_base + allocEffectEntry(fx, head),
|
||||
.method = method,
|
||||
.url = url,
|
||||
.headers = headers[0..header_count],
|
||||
.body = if (body.len > 0) body else null,
|
||||
// Wire 0 = "the engine's default" — the record
|
||||
// never bakes the default in.
|
||||
.timeout_ms = if (timeout_ms == 0) runtime_effects.default_effect_fetch_timeout_ms else timeout_ms,
|
||||
.on_response = fetchResultMsg,
|
||||
});
|
||||
// Both Cmd.fetch overloads occupy one public key
|
||||
// space. A buffered fetch must not start beside a
|
||||
// live line stream under the same key: cancel would
|
||||
// otherwise find this named op first and leave the
|
||||
// stream running. The live stream owns the key, so
|
||||
// reject the newcomer through its own err arm.
|
||||
if (head.key.len > 0 and findStream(head.key) != null) {
|
||||
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
|
||||
} else {
|
||||
fx.fetch(.{
|
||||
.key = effect_key_base + allocEffectEntry(fx, head),
|
||||
.method = method,
|
||||
.url = url,
|
||||
.headers = headers[0..header_count],
|
||||
.body = if (body.len > 0) body else null,
|
||||
// Wire 0 = "the engine's default" — the record
|
||||
// never bakes the default in.
|
||||
.timeout_ms = if (timeout_ms == 0) runtime_effects.default_effect_fetch_timeout_ms else timeout_ms,
|
||||
.on_response = fetchResultMsg,
|
||||
});
|
||||
}
|
||||
},
|
||||
// clip_write [op][bytes_len u32 LE][bytes]
|
||||
0x0A => {
|
||||
@@ -1270,6 +1295,48 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
const key_value: f64 = @bitCast(std.mem.readInt(u64, key_bits[0..8], .little));
|
||||
runAudioCaptureStop(fx, key_value);
|
||||
},
|
||||
// fetch_stream [op][key_len][key][line][ok][err]
|
||||
// [method u8][timeout u32 LE]
|
||||
// [max_line_bytes u32 LE]
|
||||
// [url_len u32 LE][url][header_count u8]
|
||||
// ([name_len u8][name][value_len u32 LE][value])*
|
||||
// [body_len u32 LE][body]
|
||||
0x20 => {
|
||||
const key = takeShortBytes(cmd, &at);
|
||||
const line_tag = takeByte(cmd, &at);
|
||||
const ok_tag = takeByte(cmd, &at);
|
||||
const err_tag = takeByte(cmd, &at);
|
||||
const method = fetchMethod(takeByte(cmd, &at));
|
||||
const timeout_bytes = takeBytes(cmd, &at, 4);
|
||||
const timeout_ms = std.mem.readInt(u32, timeout_bytes[0..4], .little);
|
||||
const max_line_bytes_wire = takeBytes(cmd, &at, 4);
|
||||
const max_line_bytes = std.mem.readInt(u32, max_line_bytes_wire[0..4], .little);
|
||||
const url = takeLongBytes(cmd, &at);
|
||||
const header_count: usize = takeByte(cmd, &at);
|
||||
if (header_count > runtime_effects.max_effect_fetch_headers) {
|
||||
@panic("ts core host: a streaming fetch wire record carries more headers than the engine accepts - the frontend's own bound should have stopped this build");
|
||||
}
|
||||
var headers: [runtime_effects.max_effect_fetch_headers]std.http.Header = undefined;
|
||||
for (0..header_count) |i| {
|
||||
const name = takeShortBytes(cmd, &at);
|
||||
const value = takeLongBytes(cmd, &at);
|
||||
headers[i] = .{ .name = name, .value = value };
|
||||
}
|
||||
const body = takeLongBytes(cmd, &at);
|
||||
issueFetchStream(fx, .{
|
||||
.key = key,
|
||||
.line_tag = line_tag,
|
||||
.exit_tag = ok_tag,
|
||||
.err_tag = err_tag,
|
||||
}, .{
|
||||
.method = method,
|
||||
.url = url,
|
||||
.headers = headers[0..header_count],
|
||||
.body = if (body.len > 0) body else null,
|
||||
.timeout_ms = if (timeout_ms == 0) runtime_effects.default_effect_fetch_timeout_ms else timeout_ms,
|
||||
.max_line_bytes = if (max_line_bytes == 0) runtime_effects.max_effect_line_bytes else max_line_bytes,
|
||||
});
|
||||
},
|
||||
else => @panic("ts core host: unknown command wire record - the core and this runtime disagree on cmd_format_version"),
|
||||
}
|
||||
}
|
||||
@@ -1384,7 +1451,7 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------ spawn streams
|
||||
// ------------------------------------------- spawn / fetch streams
|
||||
|
||||
const SpawnHead = struct { key: []const u8, line_tag: u8, exit_tag: u8, err_tag: u8 };
|
||||
|
||||
@@ -1407,8 +1474,14 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
|
||||
return;
|
||||
}
|
||||
const index = freeStreamIndex() orelse
|
||||
@panic("ts core host: more than 16 spawn streams in flight - the stream table mirrors the engine's max_effects slots");
|
||||
const index = freeStreamIndex() orelse {
|
||||
// Resource refusal is an ordinary stream terminal. The
|
||||
// engine would report the same rejection if it owned one
|
||||
// more routing slot; the bridge table must not turn that
|
||||
// public outcome into a process panic.
|
||||
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
|
||||
return;
|
||||
};
|
||||
const entry = &streams[index];
|
||||
entry.used = true;
|
||||
entry.key_len = head.key.len;
|
||||
@@ -1417,16 +1490,66 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
entry.exit_tag = head.exit_tag;
|
||||
entry.err_tag = head.err_tag;
|
||||
entry.collect = collect;
|
||||
entry.fetch = false;
|
||||
entry.damaged = false;
|
||||
fx.spawn(.{
|
||||
.key = spawn_key_base + index,
|
||||
.argv = argv,
|
||||
.stdin = if (stdin.len > 0) stdin else null,
|
||||
.output = if (collect) .collect else .lines,
|
||||
.on_line = if (head.line_tag != spawn_no_line_tag) spawnLineMsg else null,
|
||||
.on_line = if (head.line_tag != spawn_no_line_tag) streamLineMsg else null,
|
||||
.on_exit = spawnExitMsg,
|
||||
});
|
||||
}
|
||||
|
||||
const FetchStreamOptions = struct {
|
||||
method: std.http.Method,
|
||||
url: []const u8,
|
||||
headers: []const std.http.Header,
|
||||
body: ?[]const u8,
|
||||
timeout_ms: u32,
|
||||
max_line_bytes: usize,
|
||||
};
|
||||
|
||||
/// Open a line-streamed fetch in the shared stream table. Like a
|
||||
/// spawn, a duplicate live wire key is rejected rather than replaced:
|
||||
/// replacing a source that has already delivered lines would splice
|
||||
/// two HTTP responses into one app-owned stream.
|
||||
fn issueFetchStream(fx: *Fx, head: SpawnHead, options: FetchStreamOptions) void {
|
||||
if (head.key.len > 0 and
|
||||
(findStream(head.key) != null or findEffect(head.key) != null))
|
||||
{
|
||||
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
|
||||
return;
|
||||
}
|
||||
const index = freeStreamIndex() orelse {
|
||||
fx.stageLoopMsg(msgFromTagStaticBytes(head.err_tag, "rejected"));
|
||||
return;
|
||||
};
|
||||
const entry = &streams[index];
|
||||
entry.used = true;
|
||||
entry.key_len = head.key.len;
|
||||
@memcpy(entry.key[0..head.key.len], head.key);
|
||||
entry.line_tag = head.line_tag;
|
||||
entry.exit_tag = head.exit_tag;
|
||||
entry.err_tag = head.err_tag;
|
||||
entry.collect = false;
|
||||
entry.fetch = true;
|
||||
entry.damaged = false;
|
||||
fx.fetch(.{
|
||||
.key = spawn_key_base + index,
|
||||
.method = options.method,
|
||||
.url = options.url,
|
||||
.headers = options.headers,
|
||||
.body = options.body,
|
||||
.timeout_ms = options.timeout_ms,
|
||||
.response = .stream,
|
||||
.on_line = streamLineMsg,
|
||||
.max_line_bytes = options.max_line_bytes,
|
||||
.on_response = fetchStreamResultMsg,
|
||||
});
|
||||
}
|
||||
|
||||
fn findStream(key: []const u8) ?usize {
|
||||
for (&streams, 0..) |*entry, index| {
|
||||
if (entry.used and std.mem.eql(u8, entry.wireKey(), key)) return index;
|
||||
@@ -1441,26 +1564,30 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The stream entry an engine spawn result names — looked up
|
||||
/// The stream entry an engine spawn/fetch result names — looked up
|
||||
/// WITHOUT retiring (lines flow through it repeatedly; only the
|
||||
/// exit terminal retires, in `spawnExitMsg`).
|
||||
/// terminal retires it).
|
||||
fn streamAt(key: u64) *StreamEntry {
|
||||
if (key < spawn_key_base) {
|
||||
@panic("ts core host: a spawn result arrived outside the bridge's stream key namespace");
|
||||
@panic("ts core host: a stream result arrived outside the bridge's stream key namespace");
|
||||
}
|
||||
const index = key - spawn_key_base;
|
||||
if (index >= streams.len or !streams[index].used) {
|
||||
@panic("ts core host: a spawn result arrived for a stream the bridge is not tracking");
|
||||
@panic("ts core host: a result arrived for a stream the bridge is not tracking");
|
||||
}
|
||||
return &streams[index];
|
||||
}
|
||||
|
||||
/// `LineMsgFn` for spawn streams: every stdout line routes the
|
||||
/// entry's line arm with the line bytes; the entry stays live.
|
||||
/// Only set when the wire carries a line arm, so this never
|
||||
/// sees `spawn_no_line_tag`.
|
||||
fn spawnLineMsg(line: runtime_effects.EffectLine) Msg {
|
||||
/// Shared `LineMsgFn` for spawn and fetch streams: every delivered
|
||||
/// source line routes the entry's line arm; the entry stays live.
|
||||
/// Fetch streams additionally remember any cut line or preceding
|
||||
/// queue loss so their terminal cannot later claim the response was
|
||||
/// complete.
|
||||
fn streamLineMsg(line: runtime_effects.EffectLine) Msg {
|
||||
const entry = streamAt(line.key);
|
||||
if (entry.fetch and (line.truncated or line.dropped_before != 0)) {
|
||||
entry.damaged = true;
|
||||
}
|
||||
return msgFromTagBytes(entry.line_tag, line.line);
|
||||
}
|
||||
|
||||
@@ -1486,6 +1613,23 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
return msgFromTagBytes(entry.err_tag, reason);
|
||||
}
|
||||
|
||||
/// The streamed fetch's ONE terminal. A delivered, lossless response
|
||||
/// — any HTTP status, including non-2xx — routes the status through
|
||||
/// the ok arm. A cut/dropped line routes `truncated`; transport
|
||||
/// failure, timeout, rejection, and cancellation route their
|
||||
/// machine-readable outcome through err. The terminal retires the
|
||||
/// shared stream entry, so no later line can route for the key.
|
||||
fn fetchStreamResultMsg(response: runtime_effects.EffectResponse) Msg {
|
||||
const entry = streamAt(response.key);
|
||||
const damaged = entry.damaged or response.truncated or response.dropped_before != 0;
|
||||
entry.used = false;
|
||||
if (response.outcome == .ok) {
|
||||
if (damaged) return msgFromTagStaticBytes(entry.err_tag, "truncated");
|
||||
return msgFromTagNumber(entry.exit_tag, @floatFromInt(response.status));
|
||||
}
|
||||
return msgFromTagBytes(entry.err_tag, @tagName(response.outcome));
|
||||
}
|
||||
|
||||
// ------------------------------------------------- audio stream
|
||||
|
||||
/// The audio_ctl record: drive the single playback channel,
|
||||
@@ -2088,10 +2232,10 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
/// keyed tables — requests (silent drop), named engine ops
|
||||
/// (silent drop: the entry is marked dropped, the engine's
|
||||
/// `.cancelled` terminal retires it, and its Msg is swallowed —
|
||||
/// no arm dispatches), spawn streams (the child dies and its
|
||||
/// exit routes the err arm with "cancelled" — killing a process
|
||||
/// IS an observable event, so spawn's cancel stays loud), then
|
||||
/// delays (silent — a cancelled delay just never fires).
|
||||
/// no arm dispatches), spawn/fetch streams (their terminal routes
|
||||
/// the err arm with "cancelled" — ending a stream is observable,
|
||||
/// so stream cancellation stays loud), then delays (silent — a
|
||||
/// cancelled delay just never fires).
|
||||
/// Unknown keys are a no-op; the audio stream is not cancel's
|
||||
/// to end (audio_ctl `stop` closes it).
|
||||
fn cancelWireKey(fx: *Fx, key: []const u8) void {
|
||||
@@ -2106,8 +2250,8 @@ pub fn TsCoreHost(comptime core: type) type {
|
||||
return;
|
||||
}
|
||||
if (findStream(key)) |index| {
|
||||
// Same shape: the engine ends the child and the
|
||||
// `.cancelled` exit retires the entry in spawnExitMsg.
|
||||
// The engine's `.cancelled` terminal retires the entry in
|
||||
// spawnExitMsg or fetchStreamResultMsg.
|
||||
fx.cancel(spawn_key_base + index);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -317,6 +317,22 @@ const mini_core = struct {
|
||||
droppedPending: f64,
|
||||
droppedTotal: f64,
|
||||
},
|
||||
stream_get, // 86: streaming fetch "events" -> stream_line/stream_done/failed
|
||||
stream_line: []const u8, // 87: one complete response line
|
||||
stream_done: i64, // 88: terminal HTTP status
|
||||
stop_stream, // 89: cancel "events" (loud -> failed "cancelled")
|
||||
dup_stream, // 90: a second live "events" stream is rejected
|
||||
stream_over_get, // 91: streaming fetch collides with buffered fetch "get"
|
||||
get_over_stream, // 92: buffered fetch collides with streaming fetch "events"
|
||||
fill_streams, // 93: seventeen distinct streams exceed the bridge table
|
||||
};
|
||||
|
||||
const stream_fill_keys = [_][]const u8{
|
||||
"fill-00", "fill-01", "fill-02", "fill-03",
|
||||
"fill-04", "fill-05", "fill-06", "fill-07",
|
||||
"fill-08", "fill-09", "fill-10", "fill-11",
|
||||
"fill-12", "fill-13", "fill-14", "fill-15",
|
||||
"fill-16",
|
||||
};
|
||||
|
||||
pub const InitResult = struct { model: *const Model, cmd: []const u8 };
|
||||
@@ -496,6 +512,93 @@ const mini_core = struct {
|
||||
out.status = response.body;
|
||||
return .{ .model = out, .cmd = "" };
|
||||
},
|
||||
.stream_get => {
|
||||
const headers = [_]FetchHeader{.{ .name = "accept", .value = "text/event-stream" }};
|
||||
return .{ .model = model, .cmd = cmdFetchStream(
|
||||
"events",
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .stream_line)),
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .stream_done)),
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .failed)),
|
||||
1,
|
||||
60_000,
|
||||
65_536,
|
||||
"https://status.test/events",
|
||||
&headers,
|
||||
"ask",
|
||||
) };
|
||||
},
|
||||
.stream_line => |line| {
|
||||
const out = frameCreate(model.*);
|
||||
out.line_count = model.line_count + 1;
|
||||
out.last_line = line;
|
||||
return .{ .model = out, .cmd = "" };
|
||||
},
|
||||
.stream_done => |status| {
|
||||
const out = frameCreate(model.*);
|
||||
out.code = status;
|
||||
return .{ .model = out, .cmd = "" };
|
||||
},
|
||||
.stop_stream => return .{ .model = model, .cmd = cmdCancel("events") },
|
||||
.dup_stream => return .{ .model = model, .cmd = cmdFetchStream(
|
||||
"events",
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .stream_line)),
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .stream_done)),
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .failed)),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"https://status.test/other",
|
||||
&.{},
|
||||
"",
|
||||
) },
|
||||
.stream_over_get => return .{ .model = model, .cmd = cmdFetchStream(
|
||||
"get",
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .stream_line)),
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .stream_done)),
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .failed)),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"https://status.test/collision",
|
||||
&.{},
|
||||
"",
|
||||
) },
|
||||
.get_over_stream => return .{ .model = model, .cmd = cmdFetch(
|
||||
"events",
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .fetched)),
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .failed)),
|
||||
0,
|
||||
0,
|
||||
"https://status.test/collision",
|
||||
&.{},
|
||||
"",
|
||||
) },
|
||||
.fill_streams => {
|
||||
var commands: [stream_fill_keys.len][]const u8 = undefined;
|
||||
var total: usize = 0;
|
||||
for (&commands, stream_fill_keys) |*command, key| {
|
||||
command.* = cmdFetchStream(
|
||||
key,
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .stream_line)),
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .stream_done)),
|
||||
@intFromEnum(@as(std.meta.Tag(Msg), .failed)),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"https://status.test/fill",
|
||||
&.{},
|
||||
"",
|
||||
);
|
||||
total += command.len;
|
||||
}
|
||||
const out = rt.frameAlloc(u8, total);
|
||||
var off: usize = 0;
|
||||
for (commands) |command| {
|
||||
@memcpy(out[off..][0..command.len], command);
|
||||
off += command.len;
|
||||
}
|
||||
return .{ .model = model, .cmd = out };
|
||||
},
|
||||
.run_lines => return .{ .model = model, .cmd = cmdSpawn("job", 25, 26, 8, 0, &.{ "/bin/probe", "--fast" }, "feed me") },
|
||||
.run_quiet => return .{ .model = model, .cmd = cmdSpawn("job", 0xFF, 26, 8, 0, &.{"/bin/quiet"}, "") },
|
||||
.run_collect => return .{ .model = model, .cmd = cmdSpawn("job", 0xFF, 27, 8, 1, &.{ "/bin/ps", "-axo" }, "") },
|
||||
@@ -679,7 +782,7 @@ const mini_core = struct {
|
||||
@memcpy(out[first.len..], second);
|
||||
return .{ .model = model, .cmd = out };
|
||||
},
|
||||
.start_capture => return .{ .model = model, .cmd = cmdAudioCaptureStart(91, 0, 16_000, 1, 85) },
|
||||
.start_capture => return .{ .model = model, .cmd = cmdAudioCaptureStart(91, 0, 16_000, 1, @intFromEnum(@as(std.meta.Tag(Msg), .capture_evt))) },
|
||||
.stop_capture => return .{ .model = model, .cmd = cmdAudioCaptureStop(91) },
|
||||
.capture_evt => |event| {
|
||||
const out = frameCreate(model.*);
|
||||
@@ -842,6 +945,34 @@ const mini_core = struct {
|
||||
return out;
|
||||
}
|
||||
|
||||
fn cmdFetchStream(key: []const u8, line_tag: u8, ok_tag: u8, err_tag: u8, method: u8, timeout_ms: u32, max_line_bytes: u32, url: []const u8, headers: []const FetchHeader, body: []const u8) []const u8 {
|
||||
var header_bytes: usize = 0;
|
||||
for (headers) |h| header_bytes += 1 + h.name.len + 4 + h.value.len;
|
||||
const out = rt.frameAlloc(u8, 2 + key.len + 3 + 1 + 4 + 4 + 4 + url.len + 1 + header_bytes + 4 + body.len);
|
||||
out[0] = 0x20;
|
||||
out[1] = @intCast(key.len);
|
||||
@memcpy(out[2..][0..key.len], key);
|
||||
var off: usize = 2 + key.len;
|
||||
out[off] = line_tag;
|
||||
out[off + 1] = ok_tag;
|
||||
out[off + 2] = err_tag;
|
||||
out[off + 3] = method;
|
||||
std.mem.writeInt(u32, out[off + 4 ..][0..4], timeout_ms, .little);
|
||||
std.mem.writeInt(u32, out[off + 8 ..][0..4], max_line_bytes, .little);
|
||||
off += 12;
|
||||
off = writeLongBytes(out, off, url);
|
||||
out[off] = @intCast(headers.len);
|
||||
off += 1;
|
||||
for (headers) |h| {
|
||||
out[off] = @intCast(h.name.len);
|
||||
@memcpy(out[off + 1 ..][0..h.name.len], h.name);
|
||||
off += 1 + h.name.len;
|
||||
off = writeLongBytes(out, off, h.value);
|
||||
}
|
||||
_ = writeLongBytes(out, off, body);
|
||||
return out;
|
||||
}
|
||||
|
||||
fn cmdClipWrite(bytes: []const u8) []const u8 {
|
||||
const out = rt.frameAlloc(u8, 1 + 4 + bytes.len);
|
||||
out[0] = 0x0A;
|
||||
@@ -1627,6 +1758,174 @@ test "cancel is silent for every named-op family - write_file, fetch, clip_read"
|
||||
try std.testing.expectEqualStrings("", Host.model().status);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ fetch streams
|
||||
|
||||
const event_fetch_key: u64 = ts_core_host.spawn_key_base + 0;
|
||||
|
||||
test "a streaming fetch decodes whole, routes lines repeatedly, and terminates with the HTTP status" {
|
||||
const fx = freshChannel();
|
||||
defer fx.deinit();
|
||||
Host.init(fx);
|
||||
|
||||
Host.dispatch(fx, .stream_get);
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
const request = fx.pendingFetchAt(0).?;
|
||||
try std.testing.expectEqual(event_fetch_key, request.key);
|
||||
try std.testing.expectEqual(std.http.Method.POST, request.method);
|
||||
try std.testing.expectEqual(effects_mod.FetchResponseMode.stream, request.response);
|
||||
try std.testing.expectEqual(@as(usize, 65_536), request.max_line_bytes);
|
||||
try std.testing.expectEqualStrings("https://status.test/events", request.url);
|
||||
try std.testing.expectEqual(@as(usize, 1), request.headers.len);
|
||||
try std.testing.expectEqualStrings("accept", request.headers[0].name);
|
||||
try std.testing.expectEqualStrings("text/event-stream", request.headers[0].value);
|
||||
try std.testing.expectEqualStrings("ask", request.body);
|
||||
|
||||
try fx.feedLine(event_fetch_key, "data: one");
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(i64, 1), Host.model().line_count);
|
||||
try std.testing.expectEqualStrings("data: one", Host.model().last_line);
|
||||
|
||||
try fx.feedLine(event_fetch_key, "data: two");
|
||||
try fx.feedLine(event_fetch_key, "");
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(i64, 3), Host.model().line_count);
|
||||
try std.testing.expectEqualStrings("", Host.model().last_line);
|
||||
|
||||
// A non-2xx status is still a delivered response and therefore the
|
||||
// successful terminal. Stream terminals carry no body.
|
||||
try fx.feedResponse(event_fetch_key, 429, "ignored");
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(i64, 429), Host.model().code);
|
||||
try std.testing.expectError(error.EffectNotFound, fx.feedLine(event_fetch_key, "late"));
|
||||
try std.testing.expectEqual(@as(i64, 0), Host.model().errs);
|
||||
}
|
||||
|
||||
test "streaming fetch failures and cancellation are loud terminals" {
|
||||
const fx = freshChannel();
|
||||
defer fx.deinit();
|
||||
Host.init(fx);
|
||||
|
||||
Host.dispatch(fx, .stream_get);
|
||||
try fx.feedResponseOutcome(event_fetch_key, .timed_out, 0, "");
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(i64, 1), Host.model().errs);
|
||||
try std.testing.expectEqualStrings("timed_out", Host.model().last_err);
|
||||
|
||||
Host.dispatch(fx, .stream_get);
|
||||
try fx.feedLine(event_fetch_key, "queued before cancel");
|
||||
Host.dispatch(fx, .stop_stream);
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(i64, 0), Host.model().line_count);
|
||||
try std.testing.expectEqual(@as(i64, 2), Host.model().errs);
|
||||
try std.testing.expectEqualStrings("cancelled", Host.model().last_err);
|
||||
try std.testing.expectEqual(@as(usize, 0), fx.pendingFetchCount());
|
||||
}
|
||||
|
||||
test "a lossy streaming fetch terminates as truncated instead of success" {
|
||||
const fx = freshChannel();
|
||||
defer fx.deinit();
|
||||
Host.init(fx);
|
||||
|
||||
// A later delivered line reports earlier queue loss. The line still
|
||||
// routes, but even a normal HTTP terminal cannot certify the response as
|
||||
// complete afterward.
|
||||
Host.dispatch(fx, .stream_get);
|
||||
try fx.feedLineWithMetadata(event_fetch_key, "data: [DONE]", false, 2);
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(i64, 1), Host.model().line_count);
|
||||
try fx.feedResponse(event_fetch_key, 200, "");
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(i64, 1), Host.model().errs);
|
||||
try std.testing.expectEqualStrings("truncated", Host.model().last_err);
|
||||
try std.testing.expectEqual(@as(i64, -1), Host.model().code);
|
||||
|
||||
// Loss with no later line rides the response terminal itself. Cover both
|
||||
// terminal metadata fields: either one must suppress the ok arm.
|
||||
Host.dispatch(fx, .stream_get);
|
||||
try fx.feedResponseOutcomeWithMetadata(event_fetch_key, .ok, 204, "", true, 0);
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(i64, 2), Host.model().errs);
|
||||
try std.testing.expectEqualStrings("truncated", Host.model().last_err);
|
||||
try std.testing.expectEqual(@as(i64, -1), Host.model().code);
|
||||
|
||||
Host.dispatch(fx, .stream_get);
|
||||
try fx.feedResponseOutcomeWithMetadata(event_fetch_key, .ok, 206, "", false, 3);
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(i64, 3), Host.model().errs);
|
||||
try std.testing.expectEqualStrings("truncated", Host.model().last_err);
|
||||
try std.testing.expectEqual(@as(i64, -1), Host.model().code);
|
||||
}
|
||||
|
||||
test "a duplicate live streaming fetch key is rejected without replacing the stream" {
|
||||
const fx = freshChannel();
|
||||
defer fx.deinit();
|
||||
Host.init(fx);
|
||||
|
||||
Host.dispatch(fx, .stream_get);
|
||||
Host.dispatch(fx, .dup_stream);
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
try std.testing.expectEqual(@as(i64, 1), Host.model().errs);
|
||||
try std.testing.expectEqualStrings("rejected", Host.model().last_err);
|
||||
|
||||
try fx.feedLine(event_fetch_key, "original still live");
|
||||
try fx.feedResponse(event_fetch_key, 204, "");
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(i64, 1), Host.model().line_count);
|
||||
try std.testing.expectEqualStrings("original still live", Host.model().last_line);
|
||||
try std.testing.expectEqual(@as(i64, 204), Host.model().code);
|
||||
}
|
||||
|
||||
test "buffered and streaming fetch modes cannot share a live public key" {
|
||||
const fx = freshChannel();
|
||||
defer fx.deinit();
|
||||
Host.init(fx);
|
||||
|
||||
// A buffered fetch owns "get", so a streaming fetch cannot make
|
||||
// cancel ambiguous by claiming the same public key beside it.
|
||||
Host.dispatch(fx, .get);
|
||||
Host.dispatch(fx, .stream_over_get);
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
try std.testing.expectEqual(@as(i64, 1), Host.model().errs);
|
||||
try std.testing.expectEqualStrings("rejected", Host.model().last_err);
|
||||
|
||||
Host.dispatch(fx, .drop_get);
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(usize, 0), fx.pendingFetchCount());
|
||||
|
||||
// The same shared namespace applies in the opposite order. The
|
||||
// rejected buffered fetch must not hide the live stream from cancel.
|
||||
Host.dispatch(fx, .stream_get);
|
||||
Host.dispatch(fx, .get_over_stream);
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
try std.testing.expectEqual(@as(i64, 2), Host.model().errs);
|
||||
try std.testing.expectEqualStrings("rejected", Host.model().last_err);
|
||||
|
||||
Host.dispatch(fx, .stop_stream);
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(usize, 0), fx.pendingFetchCount());
|
||||
try std.testing.expectEqual(@as(i64, 3), Host.model().errs);
|
||||
try std.testing.expectEqualStrings("cancelled", Host.model().last_err);
|
||||
}
|
||||
|
||||
test "a seventeenth live stream is rejected instead of panicking" {
|
||||
const fx = freshChannel();
|
||||
defer fx.deinit();
|
||||
Host.init(fx);
|
||||
// Retire init's host request so all sixteen shared engine effect
|
||||
// slots are available to the streams this test is isolating.
|
||||
try fx.feedHostResult(boot_request_key, true, "ready");
|
||||
Host.drain(fx);
|
||||
|
||||
Host.dispatch(fx, .fill_streams);
|
||||
Host.drain(fx);
|
||||
try std.testing.expectEqual(@as(usize, 16), fx.pendingFetchCount());
|
||||
try std.testing.expectEqual(@as(i64, 1), Host.model().errs);
|
||||
try std.testing.expectEqualStrings("rejected", Host.model().last_err);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ spawn streams
|
||||
|
||||
const job_spawn_key: u64 = ts_core_host.spawn_key_base + 0;
|
||||
|
||||
@@ -1428,13 +1428,20 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
|
||||
switch (control) {
|
||||
.arm => self.effects.armReplay(),
|
||||
.feed => |record| switch (record.kind) {
|
||||
.line => try self.effects.feedLine(record.key, record.payload),
|
||||
.line => try self.effects.feedLineWithMetadata(record.key, record.payload, record.truncated, record.dropped),
|
||||
.exit => {
|
||||
if (record.payload.len > 0) try self.effects.feedOutput(record.key, record.payload);
|
||||
if (record.stderr_tail.len > 0) try self.effects.feedStderr(record.key, record.stderr_tail);
|
||||
try self.effects.feedExitReason(record.key, record.code, record.exit_reason);
|
||||
},
|
||||
.response => try self.effects.feedResponseOutcome(record.key, record.fetch_outcome, record.status, record.payload),
|
||||
.response => try self.effects.feedResponseOutcomeWithMetadata(
|
||||
record.key,
|
||||
record.fetch_outcome,
|
||||
record.status,
|
||||
record.payload,
|
||||
record.truncated,
|
||||
record.dropped,
|
||||
),
|
||||
.file => try self.effects.feedFileResult(record.key, record.file_outcome, record.payload),
|
||||
.clipboard => try self.effects.feedClipboardResult(record.key, record.clipboard_outcome, record.payload),
|
||||
// `.host` records ride the route in `code` (0 ok / 1
|
||||
|
||||
@@ -836,6 +836,7 @@ pub const RuntimeView = struct {
|
||||
pub const pruneCanvasWidgetTextHistory = CanvasWidgetTextMethods.pruneCanvasWidgetTextHistory;
|
||||
pub const clearCanvasWidgetTextVerticalGoal = CanvasWidgetTextMethods.clearCanvasWidgetTextVerticalGoal;
|
||||
pub const canEditCanvasWidgetText = CanvasWidgetTextMethods.canEditCanvasWidgetText;
|
||||
pub const ensureCanvasWidgetFocusedTextCaret = CanvasWidgetTextMethods.ensureCanvasWidgetFocusedTextCaret;
|
||||
pub const applyCanvasWidgetTextPointer = CanvasWidgetTextMethods.applyCanvasWidgetTextPointer;
|
||||
pub const clearCanvasWidgetStaticTextSelection = CanvasWidgetTextMethods.clearCanvasWidgetStaticTextSelection;
|
||||
pub const canvasWidgetCopyText = CanvasWidgetTextMethods.canvasWidgetCopyText;
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
|
||||
for (self.widget_layout_nodes[0..self.widget_layout_node_count], 0..) |node, index| {
|
||||
if (node.widget.kind != .scroll_view or canvasWidgetModelDrivenVirtual(node.widget)) continue;
|
||||
// Native drivers own momentum + rubber-band recovery.
|
||||
if (node.widget.native_scroll) continue;
|
||||
if (node.widget.runtime_flags.native_scroll) continue;
|
||||
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
|
||||
if (viewport.isEmpty()) continue;
|
||||
const physics = canvas.widgetScrollPhysics(node.widget, self.widget_tokens.scroll);
|
||||
@@ -215,7 +215,7 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
|
||||
// an engine overscroll here would have no kinetic step to
|
||||
// pull it back.
|
||||
const physics = canvas.widgetScrollPhysics(scroll_node.widget, self.widget_tokens.scroll);
|
||||
const rubberband = allow_rubberband and !scroll_node.widget.native_scroll;
|
||||
const rubberband = allow_rubberband and !scroll_node.widget.runtime_flags.native_scroll;
|
||||
const next = switch (source) {
|
||||
.wheel => if (rubberband)
|
||||
current.applyWheel(delta, physics)
|
||||
@@ -387,7 +387,7 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
|
||||
for (self.widget_layout_nodes[0..self.widget_layout_node_count], 0..) |scroll_node, scroll_index| {
|
||||
if (scroll_node.widget.kind != .scroll_view or canvasWidgetModelDrivenVirtual(scroll_node.widget)) continue;
|
||||
// Native drivers own momentum + rubber-band recovery.
|
||||
if (scroll_node.widget.native_scroll) continue;
|
||||
if (scroll_node.widget.runtime_flags.native_scroll) continue;
|
||||
|
||||
const viewport = scroll_node.frame.inset(scroll_node.widget.layout.padding).normalized();
|
||||
if (viewport.isEmpty()) {
|
||||
@@ -473,7 +473,7 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
|
||||
if (scroll_index < self.widget_layout_node_count) {
|
||||
const widget = self.widget_layout_nodes[scroll_index].widget;
|
||||
if (widget.kind == .textarea) {
|
||||
return if (widget.code_editor)
|
||||
return if (widget.runtime_flags.code_editor)
|
||||
@max(viewport.width, canvas.textInputContentWidthForWidget(widget, self.widget_tokens))
|
||||
else
|
||||
viewport.width;
|
||||
@@ -524,7 +524,7 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
|
||||
}
|
||||
if (widget.kind != .textarea) return;
|
||||
|
||||
if (widget.code_editor and widget.text_no_wrap) {
|
||||
if (widget.runtime_flags.code_editor and widget.text_no_wrap) {
|
||||
const next_x = canvas.textInputCaretVisibleScrollOffsetForWidget(widget, self.widget_tokens, widget.value_x);
|
||||
if (next_x != widget.value_x) {
|
||||
widget.value_x = next_x;
|
||||
|
||||
@@ -179,14 +179,16 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Multi-line editing contract: Enter (plain or shift) inserts
|
||||
// a newline; submit rides the primary-modifier chord instead.
|
||||
// Shared with the app dispatch path so the model's `on_input`
|
||||
// hears exactly the edit the retained text applied.
|
||||
// Multi-line editing contract: Enter normally inserts a
|
||||
// newline, while a submit-on-enter textarea leaves plain
|
||||
// Enter for its submit handler and keeps Shift+Enter as the
|
||||
// newline gesture. Shared with the app dispatch path so the
|
||||
// model's `on_input` hears exactly the edit retained text
|
||||
// applied.
|
||||
if (canvas.widgetCodeTabTextEditEvent(widget, keyboard)) |tab_edit| {
|
||||
return tab_edit;
|
||||
}
|
||||
if (canvas.widgetKeyboardNewlineTextEditEvent(widget.kind, keyboard)) |newline_edit| {
|
||||
if (canvas.widgetKeyboardNewlineTextEditEvent(widget, keyboard)) |newline_edit| {
|
||||
return newline_edit;
|
||||
}
|
||||
|
||||
@@ -959,6 +961,23 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
|
||||
return canvasWidgetEditableTextKind(widget.kind) and !widget.state.disabled;
|
||||
}
|
||||
|
||||
/// A controlled source replacement intentionally discards stale
|
||||
/// selection state, but logical focus survives that rebuild. Give
|
||||
/// the focused editor a fresh insertion point so an app clearing a
|
||||
/// submitted composer never leaves a focused-but-caretless field.
|
||||
/// Explicit source selections are left untouched.
|
||||
pub fn ensureCanvasWidgetFocusedTextCaret(self: *RuntimeView) anyerror!bool {
|
||||
const focused_id = self.canvas_widget_focused_id;
|
||||
if (focused_id == 0 or !self.canEditCanvasWidgetText(focused_id)) return false;
|
||||
const index = self.canvasWidgetNodeIndexById(focused_id) orelse return false;
|
||||
const widget = &self.widget_layout_nodes[index].widget;
|
||||
if (widget.text_selection != null) return false;
|
||||
widget.text_selection = canvas.TextSelection.collapsed(widget.text.len);
|
||||
try self.refreshCanvasWidgetSemantics();
|
||||
self.widget_revision += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn applyCanvasWidgetTextPointer(
|
||||
self: *RuntimeView,
|
||||
target_id: canvas.ObjectId,
|
||||
|
||||
@@ -54,7 +54,7 @@ fi
|
||||
|
||||
case "$fixture" in
|
||||
ai-chat)
|
||||
source_root="examples/ai-chat-ts/src"
|
||||
source_root="examples/chatbot/src"
|
||||
sources="core.ts api.ts"
|
||||
contract="ai-chat"
|
||||
;;
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
//! End-to-end proof battery for examples/ai-chat-ts — the "can I call an
|
||||
//! AI API?" answer as a real app: a chat client for an OpenAI-compatible
|
||||
//! chat-completions endpoint authored in TypeScript + Native markup with
|
||||
//! End-to-end proof battery for examples/chatbot — the "can I call an
|
||||
//! AI API?" answer as a real app: a streaming Vercel AI Gateway chat
|
||||
//! client authored in TypeScript + Native markup with
|
||||
//! ZERO hand-written Zig. The build compiles the example's REAL core through the external core compiler
|
||||
//! (examples/ai-chat-ts/src/core.ts + src/api.ts) and this suite drives
|
||||
//! (examples/chatbot/src/core.ts + src/api.ts) and this suite drives
|
||||
//! it through `TsUiApp` with the example's SHIPPING markup (app.native,
|
||||
//! staged beside this file), so every pin here is the product path:
|
||||
//!
|
||||
//! - the launch configuration rides the envMsgs channel, and the
|
||||
//! teaching state holds (with ZERO fetches) until the endpoint, the
|
||||
//! model name, AND the API key are all present;
|
||||
//! - the API key and optional initial model override ride the envMsgs
|
||||
//! channel, the prompt-group picker lists Luna, Terra, then Sol,
|
||||
//! while the teaching state holds (with ZERO fetches) until the API
|
||||
//! key is present;
|
||||
//! - a scripted two-turn conversation drives the whole loop through
|
||||
//! the fake fetch feed: the composer's byte-splice text engine, the
|
||||
//! Send press, the EXACT request bytes (method, the bare endpoint
|
||||
//! url, the runtime-built `authorization: Bearer <key>` header plus
|
||||
//! the content-type header, the JSON body — system prompt first,
|
||||
//! history growing turn by turn), and the response parse into the
|
||||
//! committed model;
|
||||
//! Send press, the EXACT streaming request (the fixed Gateway URL,
|
||||
//! runtime-built `authorization: Bearer <key>`, SSE accept header,
|
||||
//! and JSON body — system prompt first, history growing turn by
|
||||
//! turn), then asserts each SSE delta appears before the terminal;
|
||||
//! - the in-flight guard: a second send while one request is out
|
||||
//! issues nothing and loses nothing (the draft survives);
|
||||
//! - every failure shape lands in the failed state with a reason and
|
||||
//! KEEPS the history: a 500 with an error body (the endpoint's own
|
||||
//! error.message surfaces), a 200 whose body does not parse, a bare
|
||||
//! non-200, a transport failure — and Retry re-sends the same
|
||||
//! conversation;
|
||||
//! KEEPS the history: a 500 with an error line (the Gateway's own
|
||||
//! error.message surfaces), a stream missing [DONE], a bare non-200,
|
||||
//! a transport failure — and Retry re-sends the same conversation;
|
||||
//! - a recorded conversation REPLAYS BYTE-IDENTICALLY with zero host
|
||||
//! calls — no endpoint, no network in the room (the journaled fetch
|
||||
//! calls — no network in the room (the journaled fetch
|
||||
//! results feed the replayed requests), and with ZERO env reads:
|
||||
//! the launch configuration is journaled (`.env` records) at record
|
||||
//! time, so the replay launches with the variables UNSET and again
|
||||
@@ -54,11 +53,16 @@ const CompiledAppView = canvas.CompiledMarkupView(core.Model, core.Msg, app_mark
|
||||
const canvas_label = "chat-canvas";
|
||||
|
||||
/// The one fetch key's engine slot: the "chat" request is the first (and
|
||||
/// only) named engine op the core issues, so it takes bridge op slot 0,
|
||||
/// only) stream the core issues, so it takes stream-table slot 0,
|
||||
/// deterministically in issue order.
|
||||
const chat_fetch_key: u64 = runtime_ns.ts_core_effect_key_base + 0;
|
||||
const chat_fetch_key: u64 = runtime_ns.ts_core_spawn_key_base + 0;
|
||||
|
||||
const test_endpoint = "http://chat.test/v1/chat/completions";
|
||||
const test_endpoint = "https://ai-gateway.vercel.sh/v1/chat/completions";
|
||||
const sol_model_name = "openai/gpt-5.6-sol";
|
||||
const default_model_name = "openai/gpt-5.6-luna";
|
||||
const default_model_label = "GPT-5.6 Luna";
|
||||
const terra_model_label = "GPT-5.6 Terra";
|
||||
const sol_model_label = "GPT-5.6 Sol";
|
||||
const test_model_name = "test-model";
|
||||
const test_api_key = "test-key";
|
||||
|
||||
@@ -67,12 +71,17 @@ const app_views = [_]native_sdk.ShellView{
|
||||
};
|
||||
const app_windows = [_]native_sdk.ShellWindow{.{
|
||||
.label = "main",
|
||||
.title = "AI Chat TS",
|
||||
.title = "Chatbot",
|
||||
.width = 760,
|
||||
.height = 640,
|
||||
.titlebar = .hidden_inset_tall,
|
||||
.views = &app_views,
|
||||
}};
|
||||
const app_scene: native_sdk.ShellConfig = .{ .windows = &app_windows };
|
||||
const test_window_chrome: native_sdk.WindowChrome = .{
|
||||
.insets = .{ .top = 52, .left = 78 },
|
||||
.buttons = geometry.RectF.init(12, 18, 54, 16),
|
||||
};
|
||||
|
||||
/// TEST-ONLY command mapper: the journaled menu-command path for the
|
||||
/// void arms (record/replay needs every input in the journal; the app
|
||||
@@ -93,7 +102,7 @@ fn testCommand(name: []const u8) ?core.Msg {
|
||||
|
||||
fn appOptions() App.Options {
|
||||
return .{
|
||||
.name = "ai-chat-ts-e2e",
|
||||
.name = "chatbot-e2e",
|
||||
.scene = app_scene,
|
||||
.canvas_label = canvas_label,
|
||||
// The comptime-compiled engine over the example's shipping markup
|
||||
@@ -105,7 +114,6 @@ fn appOptions() App.Options {
|
||||
|
||||
/// The full launch configuration — every variable present.
|
||||
const configured_env = [_]Adapter.EnvValue{
|
||||
.{ .msg = "endpoint_set", .value = test_endpoint },
|
||||
.{ .msg = "model_set", .value = test_model_name },
|
||||
.{ .msg = "key_set", .value = test_api_key },
|
||||
};
|
||||
@@ -117,8 +125,8 @@ const Harness = struct {
|
||||
clock: native_sdk.TestClock,
|
||||
|
||||
/// A configured app on the FAKE effects executor: fetch requests
|
||||
/// park in fake slots for `feedResponse` answers instead of
|
||||
/// reaching a network.
|
||||
/// park in fake slots for scripted SSE lines and terminals instead
|
||||
/// of reaching a network.
|
||||
fn create() !*Harness {
|
||||
return createConfigured(null, &configured_env);
|
||||
}
|
||||
@@ -133,6 +141,10 @@ const Harness = struct {
|
||||
});
|
||||
errdefer self.harness.destroy(std.testing.allocator);
|
||||
self.harness.null_platform.gpu_surfaces = true;
|
||||
self.harness.null_platform.window_chrome = test_window_chrome;
|
||||
// Match the shipping desktop path: conversation scroll regions
|
||||
// are backed by native scroll drivers, not the engine fallback.
|
||||
self.harness.null_platform.gpu_surface_scroll_drivers = true;
|
||||
self.harness.runtime.options.session_recorder = recorder;
|
||||
self.app_state = try std.testing.allocator.create(App);
|
||||
errdefer std.testing.allocator.destroy(self.app_state);
|
||||
@@ -179,6 +191,13 @@ const Harness = struct {
|
||||
return findByLabel(self.app_state.tree.?.root, label);
|
||||
}
|
||||
|
||||
fn focusedWidgetId(self: *Harness) canvas.ObjectId {
|
||||
for (self.harness.runtime.views[0..self.harness.runtime.view_count]) |view| {
|
||||
if (std.mem.eql(u8, view.label, canvas_label)) return view.canvas_widget_focused_id;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Click a rendered widget through the automation verb — the same
|
||||
/// headless path `native automate` drives.
|
||||
fn click(self: *Harness, id: canvas.ObjectId) !void {
|
||||
@@ -205,6 +224,16 @@ const Harness = struct {
|
||||
} });
|
||||
}
|
||||
|
||||
fn keyDownShift(self: *Harness, key: []const u8) !void {
|
||||
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
|
||||
.window_id = 1,
|
||||
.label = canvas_label,
|
||||
.kind = .key_down,
|
||||
.key = key,
|
||||
.modifiers = .{ .shift = true },
|
||||
} });
|
||||
}
|
||||
|
||||
/// Focus the composer, type the message, and press Send — the whole
|
||||
/// user gesture through the real input path.
|
||||
fn say(self: *Harness, text: []const u8) !void {
|
||||
@@ -213,12 +242,27 @@ const Harness = struct {
|
||||
try self.click(self.findLabel("Send message").?);
|
||||
}
|
||||
|
||||
/// Answer the parked chat request with a scripted HTTP response and
|
||||
/// drain the result into the core.
|
||||
fn respond(self: *Harness, status: u16, body: []const u8) !void {
|
||||
try self.app_state.effects.feedResponse(chat_fetch_key, status, body);
|
||||
/// Deliver one complete response line and rebuild immediately —
|
||||
/// this is the observable token-by-token path under test.
|
||||
fn line(self: *Harness, bytes: []const u8) !void {
|
||||
try self.app_state.effects.feedLine(chat_fetch_key, bytes);
|
||||
try self.wake();
|
||||
}
|
||||
|
||||
/// Deliver the stream's terminal status (stream terminals have no
|
||||
/// body) and rebuild.
|
||||
fn finish(self: *Harness, status: u16) !void {
|
||||
try self.app_state.effects.feedResponse(chat_fetch_key, status, "");
|
||||
try self.wake();
|
||||
}
|
||||
|
||||
/// Complete a normal one-delta Gateway response. Tests that assert
|
||||
/// incremental rendering feed their own lines instead.
|
||||
fn complete(self: *Harness, delta_line: []const u8) !void {
|
||||
try self.line(delta_line);
|
||||
try self.line("data: [DONE]");
|
||||
try self.finish(200);
|
||||
}
|
||||
};
|
||||
|
||||
fn findKindText(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) ?canvas.ObjectId {
|
||||
@@ -229,6 +273,14 @@ fn findKindText(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8
|
||||
return null;
|
||||
}
|
||||
|
||||
fn findWidgetKindText(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) ?canvas.Widget {
|
||||
if (widget.kind == kind and std.mem.eql(u8, widget.text, text)) return widget;
|
||||
for (widget.children) |child| {
|
||||
if (findWidgetKindText(child, kind, text)) |found| return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn findTextIn(widget: canvas.Widget, text: []const u8) bool {
|
||||
if (std.mem.indexOf(u8, widget.text, text) != null) return true;
|
||||
for (widget.children) |child| {
|
||||
@@ -259,26 +311,34 @@ fn findWidgetByLabel(widget: canvas.Widget, label: []const u8) ?canvas.Widget {
|
||||
const first_request_body =
|
||||
"{\"model\":\"test-model\",\"messages\":[" ++
|
||||
"{\"role\":\"system\",\"content\":\"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.\"}," ++
|
||||
"{\"role\":\"user\",\"content\":\"Say hi in two words\"}]}";
|
||||
"{\"role\":\"user\",\"content\":\"Say hi in two words\"}],\"stream\":true}";
|
||||
const second_request_body =
|
||||
"{\"model\":\"test-model\",\"messages\":[" ++
|
||||
"{\"role\":\"system\",\"content\":\"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.\"}," ++
|
||||
"{\"role\":\"user\",\"content\":\"Say hi in two words\"}," ++
|
||||
"{\"role\":\"assistant\",\"content\":\"Hi there!\"}," ++
|
||||
"{\"role\":\"user\",\"content\":\"Now say it in Zig \\\"strings\\\"\"}]}";
|
||||
"{\"role\":\"user\",\"content\":\"Now say it in Zig \\\"strings\\\"\"}],\"stream\":true}";
|
||||
const default_request_body =
|
||||
"{\"model\":\"openai/gpt-5.6-luna\",\"messages\":[" ++
|
||||
"{\"role\":\"system\",\"content\":\"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.\"}," ++
|
||||
"{\"role\":\"user\",\"content\":\"Use the default\"}],\"stream\":true}";
|
||||
const sol_request_body =
|
||||
"{\"model\":\"openai/gpt-5.6-sol\",\"messages\":[" ++
|
||||
"{\"role\":\"system\",\"content\":\"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.\"}," ++
|
||||
"{\"role\":\"user\",\"content\":\"Use Sol\"}],\"stream\":true}";
|
||||
|
||||
// ---------------------------------------------------- the teaching state
|
||||
|
||||
test "the teaching state holds until every launch variable arrives - and issues zero fetches" {
|
||||
// No variables at all: the setup panel teaches all three names.
|
||||
test "the teaching state only requires the Gateway key and the model has a working default" {
|
||||
// No variables at all: the setup panel teaches the required key and
|
||||
// names the built-in model plus its optional override.
|
||||
{
|
||||
const h = try Harness.createConfigured(null, &.{});
|
||||
defer h.destroy();
|
||||
try std.testing.expect(h.hasText("Connect a model"));
|
||||
try std.testing.expect(h.hasText("NATIVE_SDK_CHAT_ENDPOINT"));
|
||||
try std.testing.expect(h.hasText("NATIVE_SDK_CHAT_MODEL"));
|
||||
try std.testing.expect(h.hasText("NATIVE_SDK_CHAT_API_KEY"));
|
||||
try std.testing.expect(h.hasText("no model configured"));
|
||||
try std.testing.expect(h.hasText("AI_GATEWAY_API_KEY"));
|
||||
try std.testing.expect(h.hasText(default_model_name));
|
||||
// The composer does not exist in the teaching state, and even a
|
||||
// journaled send dispatch issues nothing.
|
||||
try std.testing.expect(h.findLabel("Message") == null);
|
||||
@@ -286,26 +346,86 @@ test "the teaching state holds until every launch variable arrives - and issues
|
||||
try std.testing.expectEqual(@as(usize, 0), h.app_state.effects.pendingFetchCount());
|
||||
}
|
||||
|
||||
// Endpoint and model present, the key EMPTY (the variable exists but
|
||||
// holds nothing): still the teaching state, still zero fetches — an
|
||||
// unkeyed app never dials the endpoint.
|
||||
// Model present, the key EMPTY (the variable exists but holds
|
||||
// nothing): still the teaching state, still zero fetches — an
|
||||
// unkeyed app never dials the Gateway.
|
||||
{
|
||||
const partial_env = [_]Adapter.EnvValue{
|
||||
.{ .msg = "endpoint_set", .value = test_endpoint },
|
||||
.{ .msg = "model_set", .value = test_model_name },
|
||||
.{ .msg = "key_set", .value = "" },
|
||||
};
|
||||
const h = try Harness.createConfigured(null, &partial_env);
|
||||
defer h.destroy();
|
||||
try std.testing.expect(h.hasText("Connect a model"));
|
||||
// The two configured rows read "set"; the key row still teaches.
|
||||
try std.testing.expect(h.hasText("set"));
|
||||
// The optional override landed in the model; the prompt group is
|
||||
// absent until configured, and the key row still teaches.
|
||||
try std.testing.expect(h.hasText("missing"));
|
||||
try std.testing.expect(h.hasText(test_model_name));
|
||||
try std.testing.expectEqualStrings(test_model_name, Bridge.model().modelName);
|
||||
try std.testing.expect(!h.hasText(test_model_name));
|
||||
try h.menu("chat.send");
|
||||
try std.testing.expectEqual(@as(usize, 0), h.app_state.effects.pendingFetchCount());
|
||||
try std.testing.expect(Bridge.model().unconfigured());
|
||||
}
|
||||
|
||||
// With only the required key, the teaching state clears and the
|
||||
// first request uses the built-in model byte-for-byte.
|
||||
{
|
||||
const key_only_env = [_]Adapter.EnvValue{
|
||||
.{ .msg = "key_set", .value = test_api_key },
|
||||
};
|
||||
const h = try Harness.createConfigured(null, &key_only_env);
|
||||
defer h.destroy();
|
||||
try std.testing.expect(!h.hasText("Connect a model"));
|
||||
try std.testing.expectEqualStrings(default_model_name, Bridge.model().modelName);
|
||||
try std.testing.expect(h.hasText(default_model_label));
|
||||
try std.testing.expect(!Bridge.model().unconfigured());
|
||||
try h.say("Use the default");
|
||||
try std.testing.expectEqual(@as(usize, 1), h.app_state.effects.pendingFetchCount());
|
||||
try std.testing.expectEqualStrings(default_request_body, h.app_state.effects.pendingFetchAt(0).?.body);
|
||||
}
|
||||
}
|
||||
|
||||
test "the prompt-group model selector changes the model used by the next request" {
|
||||
const key_only_env = [_]Adapter.EnvValue{
|
||||
.{ .msg = "key_set", .value = test_api_key },
|
||||
};
|
||||
const h = try Harness.createConfigured(null, &key_only_env);
|
||||
defer h.destroy();
|
||||
|
||||
const prompt = findWidgetByLabel(h.app_state.tree.?.root, "Prompt composer").?;
|
||||
const selector = findWidgetByLabel(prompt, "Model selector").?;
|
||||
try std.testing.expectEqual(canvas.WidgetKind.select, selector.kind);
|
||||
try std.testing.expect(!selector.state.disabled);
|
||||
try std.testing.expectEqualStrings(default_model_label, selector.text);
|
||||
try h.click(selector.id);
|
||||
try std.testing.expect(Bridge.model().modelPickerOpen);
|
||||
|
||||
const models = findWidgetByLabel(h.app_state.tree.?.root, "Models").?;
|
||||
try std.testing.expectEqual(@as(usize, 3), models.children.len);
|
||||
try std.testing.expectEqualStrings(default_model_label, models.children[0].text);
|
||||
try std.testing.expectEqualStrings(terra_model_label, models.children[1].text);
|
||||
try std.testing.expectEqualStrings(sol_model_label, models.children[2].text);
|
||||
const luna = findWidgetKindText(models, .menu_item, default_model_label).?;
|
||||
const terra = findWidgetKindText(models, .menu_item, terra_model_label).?;
|
||||
const sol = findWidgetKindText(models, .menu_item, sol_model_label).?;
|
||||
try std.testing.expect(!sol.state.selected);
|
||||
try std.testing.expect(luna.state.selected);
|
||||
try std.testing.expect(!terra.state.selected);
|
||||
const picker_layout = try h.harness.runtime.canvasWidgetLayout(1, canvas_label);
|
||||
const selector_frame = picker_layout.findById(selector.id).?.frame.normalized();
|
||||
const models_frame = picker_layout.findById(models.id).?.frame.normalized();
|
||||
try std.testing.expectEqual(@as(f32, 148), selector_frame.width);
|
||||
try std.testing.expectEqual(@as(f32, 148), models_frame.width);
|
||||
|
||||
try h.click(sol.id);
|
||||
try std.testing.expect(!Bridge.model().modelPickerOpen);
|
||||
try std.testing.expectEqualStrings(sol_model_name, Bridge.model().modelName);
|
||||
try std.testing.expect(h.findId(.menu_item, sol_model_label) == null);
|
||||
try std.testing.expectEqual(h.findLabel("Message").?, h.focusedWidgetId());
|
||||
|
||||
try h.say("Use Sol");
|
||||
try std.testing.expectEqual(@as(usize, 1), h.app_state.effects.pendingFetchCount());
|
||||
try std.testing.expectEqualStrings(sol_request_body, h.app_state.effects.pendingFetchAt(0).?.body);
|
||||
}
|
||||
|
||||
test "the runtime markup interpreter builds the emitted model exactly like the compiled engine" {
|
||||
@@ -335,7 +455,7 @@ test "the runtime markup interpreter builds the emitted model exactly like the c
|
||||
try collectTexts(interpreted.root, &interpreted_texts, std.testing.allocator);
|
||||
try collectTexts(compiled.root, &compiled_texts, std.testing.allocator);
|
||||
try std.testing.expectEqualStrings(interpreted_texts.items, compiled_texts.items);
|
||||
try std.testing.expect(std.mem.indexOf(u8, compiled_texts.items, "Ask anything") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, compiled_texts.items, "What can I help with?") != null);
|
||||
}
|
||||
|
||||
fn collectTexts(widget: canvas.Widget, out: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator) !void {
|
||||
@@ -348,32 +468,95 @@ fn collectTexts(widget: canvas.Widget, out: *std.ArrayListUnmanaged(u8), allocat
|
||||
|
||||
// ------------------------------------------------------ the conversation
|
||||
|
||||
test "a scripted conversation pins the exact request bytes, the parse, and the history growth" {
|
||||
test "chat content fills narrow windows and caps centered below the full-width titlebar" {
|
||||
const h = try Harness.create();
|
||||
defer h.destroy();
|
||||
|
||||
const narrow = try h.harness.runtime.canvasWidgetLayout(1, canvas_label);
|
||||
const narrow_header = narrow.findById(h.findLabel("Chat header").?).?.frame.normalized();
|
||||
const narrow_content = narrow.findById(h.findLabel("Chat content").?).?.frame.normalized();
|
||||
try std.testing.expectEqual(@as(f32, 760), narrow_header.width);
|
||||
try std.testing.expectEqual(@as(f32, 760), narrow_content.width);
|
||||
try std.testing.expectEqual(@as(f32, 0), narrow_content.x);
|
||||
|
||||
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_frame = .{
|
||||
.label = canvas_label,
|
||||
.size = geometry.SizeF.init(1600, 640),
|
||||
.scale_factor = 1,
|
||||
.frame_index = 2,
|
||||
.timestamp_ns = 2_000_000,
|
||||
} });
|
||||
const wide = try h.harness.runtime.canvasWidgetLayout(1, canvas_label);
|
||||
const wide_header = wide.findById(h.findLabel("Chat header").?).?.frame.normalized();
|
||||
const wide_content = wide.findById(h.findLabel("Chat content").?).?.frame.normalized();
|
||||
try std.testing.expectEqual(@as(f32, 1600), wide_header.width);
|
||||
try std.testing.expectEqual(@as(f32, 960), wide_content.width);
|
||||
try std.testing.expectEqual(@as(f32, 320), wide_content.x);
|
||||
}
|
||||
|
||||
test "a scripted conversation pins the Gateway request and renders each SSE delta before completion" {
|
||||
const h = try Harness.create();
|
||||
defer h.destroy();
|
||||
const fx = &h.app_state.effects;
|
||||
|
||||
// The configured idle state: the model badge and the empty hint.
|
||||
// The configured idle state: a text-free custom titlebar and the
|
||||
// empty-conversation hint. The New chat button is nested inside the
|
||||
// draggable row but remains its own interactive exclusion.
|
||||
const header = findWidgetByLabel(h.app_state.tree.?.root, "Chat header").?;
|
||||
try std.testing.expect(header.window_drag);
|
||||
try std.testing.expectEqual(@as(f64, 78), Bridge.model().chromeLeading);
|
||||
try std.testing.expectEqual(@as(f64, 52), Bridge.model().headerHeight);
|
||||
const new_chat = findWidgetByLabel(h.app_state.tree.?.root, "New chat").?;
|
||||
try std.testing.expect(!new_chat.state.disabled);
|
||||
try std.testing.expect(!h.hasText("Chatbot"));
|
||||
try std.testing.expect(h.hasText(test_model_name));
|
||||
try std.testing.expect(h.hasText("Ask anything"));
|
||||
try std.testing.expect(h.hasText("What can I help with?"));
|
||||
try std.testing.expect(h.hasText("Ask a question, write code, or explore ideas."));
|
||||
const empty_state = findWidgetByLabel(h.app_state.tree.?.root, "Empty conversation").?;
|
||||
try std.testing.expectEqual(canvas.WidgetMainAlignment.center, empty_state.layout.main_alignment);
|
||||
try std.testing.expectEqual(canvas.WidgetCrossAlignment.center, empty_state.layout.cross_alignment);
|
||||
|
||||
// Type into the composer through the real text-input path (the
|
||||
// core's byte-splice engine) and send. The engine channel holds the
|
||||
// request whole: POST, the bare configured endpoint, the
|
||||
// runtime-built `authorization: Bearer <key>` header (header VALUES
|
||||
// may be runtime bytes; the key never rides the URL) plus the JSON
|
||||
// content-type header in name-sort order, and the byte-exact body.
|
||||
try h.say("Say hi in two words");
|
||||
// The prompt is one grouped field: a multiline entry first, then the
|
||||
// model selector and icon-only arrow submit action inside its border.
|
||||
const prompt = findWidgetByLabel(h.app_state.tree.?.root, "Prompt composer").?;
|
||||
try std.testing.expectEqual(canvas.WidgetKind.input_group, prompt.kind);
|
||||
try std.testing.expectEqual(@as(usize, 2), prompt.children.len);
|
||||
try std.testing.expect(findWidgetByLabel(prompt, "Model selector") != null);
|
||||
const message_entry = findWidgetByLabel(prompt, "Message").?;
|
||||
try std.testing.expectEqual(canvas.WidgetKind.textarea, message_entry.kind);
|
||||
try std.testing.expect(message_entry.submit_on_enter);
|
||||
const send_button = findWidgetByLabel(prompt, "Send message").?;
|
||||
try std.testing.expectEqual(canvas.WidgetKind.button, send_button.kind);
|
||||
try std.testing.expectEqualStrings("arrow-up", send_button.icon);
|
||||
try std.testing.expectEqualStrings("", send_button.text);
|
||||
|
||||
// The composer takes focus when the configured app mounts, so text
|
||||
// input lands there without a click. Type through that real input
|
||||
// path (the core's byte-splice engine) and press plain Enter. The
|
||||
// engine channel holds the request whole: POST, the fixed Vercel AI
|
||||
// Gateway endpoint, and the runtime-built
|
||||
// `authorization: Bearer <key>` header (header VALUES
|
||||
// may be runtime bytes; the key never rides the URL), the SSE accept
|
||||
// header and JSON content type in name-sort order, and the byte-exact
|
||||
// body with `stream: true`.
|
||||
const composer_id = h.findLabel("Message").?;
|
||||
try std.testing.expectEqual(composer_id, h.focusedWidgetId());
|
||||
try h.textInput("Say hi in two words");
|
||||
try h.keyDown("enter");
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
const request = fx.pendingFetchAt(0).?;
|
||||
try std.testing.expectEqual(chat_fetch_key, request.key);
|
||||
try std.testing.expectEqual(std.http.Method.POST, request.method);
|
||||
try std.testing.expectEqual(runtime_ns.FetchResponseMode.stream, request.response);
|
||||
try std.testing.expectEqual(@as(usize, 65_536), request.max_line_bytes);
|
||||
try std.testing.expectEqualStrings(test_endpoint, request.url);
|
||||
try std.testing.expectEqual(@as(usize, 2), request.headers.len);
|
||||
try std.testing.expectEqualStrings("authorization", request.headers[0].name);
|
||||
try std.testing.expectEqualStrings("Bearer " ++ test_api_key, request.headers[0].value);
|
||||
try std.testing.expectEqualStrings("content-type", request.headers[1].name);
|
||||
try std.testing.expectEqualStrings("application/json", request.headers[1].value);
|
||||
try std.testing.expectEqual(@as(usize, 3), request.headers.len);
|
||||
try std.testing.expectEqualStrings("accept", request.headers[0].name);
|
||||
try std.testing.expectEqualStrings("text/event-stream", request.headers[0].value);
|
||||
try std.testing.expectEqualStrings("authorization", request.headers[1].name);
|
||||
try std.testing.expectEqualStrings("Bearer " ++ test_api_key, request.headers[1].value);
|
||||
try std.testing.expectEqualStrings("content-type", request.headers[2].name);
|
||||
try std.testing.expectEqualStrings("application/json", request.headers[2].value);
|
||||
try std.testing.expectEqualStrings(first_request_body, request.body);
|
||||
|
||||
// The optimistic model: the user turn committed, the draft cleared,
|
||||
@@ -387,11 +570,43 @@ test "a scripted conversation pins the exact request bytes, the parse, and the h
|
||||
defer draft_arena.deinit();
|
||||
try std.testing.expectEqual(@as(usize, 0), Bridge.model().draftText(draft_arena.allocator()).len);
|
||||
}
|
||||
try std.testing.expect(h.hasText("waiting for the model"));
|
||||
try std.testing.expectEqual(composer_id, h.focusedWidgetId());
|
||||
const submitted_layout = try h.harness.runtime.canvasWidgetLayout(1, canvas_label);
|
||||
try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(0), submitted_layout.findById(composer_id).?.widget.text_selection.?);
|
||||
try std.testing.expect(h.hasText("…"));
|
||||
{
|
||||
const layout = try h.harness.runtime.canvasWidgetLayout(1, canvas_label);
|
||||
const conversation = layout.findById(h.findLabel("Conversation").?).?;
|
||||
const history = findWidgetByLabel(h.app_state.tree.?.root, "Conversation history").?;
|
||||
const prompt_frame = layout.findById(h.findLabel("Prompt composer").?).?.frame.normalized();
|
||||
const user_turn = layout.findById(h.findLabel("You said").?).?;
|
||||
try std.testing.expectEqual(@as(f32, 24), history.layout.padding.bottom);
|
||||
try std.testing.expectApproxEqAbs(conversation.frame.normalized().maxY(), prompt_frame.y, 0.01);
|
||||
try std.testing.expect(conversation.frame.intersects(user_turn.frame));
|
||||
}
|
||||
|
||||
// The endpoint answers; the reply parses out of choices[0] (escapes
|
||||
// decoded) and joins the history as the assistant turn.
|
||||
try h.respond(200, "{ \"id\": \"c-1\", \"object\": \"chat.completion\", \"choices\": [ { \"index\": 0, \"message\": { \"role\": \"assistant\", \"content\": \"Hi there!\" }, \"finish_reason\": \"stop\" } ], \"usage\": { \"total_tokens\": 7 } }");
|
||||
// A role-only chunk is a valid no-op. Then each content delta lands
|
||||
// in committed pendingReply and is visible BEFORE [DONE] or the HTTP
|
||||
// terminal — this is the behavior an AI chat UI needs.
|
||||
try h.line("data: {\"id\":\"c-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}");
|
||||
try std.testing.expectEqualStrings("", Bridge.model().pendingReply);
|
||||
try h.line("data: {\"id\":\"c-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi \"}}]}");
|
||||
try std.testing.expect(Bridge.model().phase == .sending);
|
||||
try std.testing.expectEqualStrings("Hi ", Bridge.model().pendingReply);
|
||||
try std.testing.expect(h.hasText("Hi "));
|
||||
{
|
||||
const layout = try h.harness.runtime.canvasWidgetLayout(1, canvas_label);
|
||||
const conversation = layout.findById(h.findLabel("Conversation").?).?;
|
||||
const pending_reply = layout.findById(h.findLabel("Reply pending").?).?;
|
||||
try std.testing.expect(conversation.frame.intersects(pending_reply.frame));
|
||||
}
|
||||
try h.line("data:{\"choices\":[{\"delta\":{\"content\":\"there!\"},\"finish_reason\":\"stop\"}]}\r");
|
||||
try std.testing.expectEqualStrings("Hi there!", Bridge.model().pendingReply);
|
||||
try std.testing.expect(h.hasText("Hi there!"));
|
||||
try h.line("data: [DONE]");
|
||||
try std.testing.expect(Bridge.model().phase == .sending);
|
||||
try std.testing.expect(Bridge.model().streamDone);
|
||||
try h.finish(200);
|
||||
try std.testing.expect(Bridge.model().phase == .idle);
|
||||
try std.testing.expectEqual(@as(usize, 2), Bridge.model().turns.len);
|
||||
try std.testing.expect(Bridge.model().turns[1].role == .assistant);
|
||||
@@ -404,13 +619,117 @@ test "a scripted conversation pins the exact request bytes, the parse, and the h
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
try std.testing.expectEqualStrings(second_request_body, fx.pendingFetchAt(0).?.body);
|
||||
|
||||
// A reply whose content carries JSON escapes decodes into real
|
||||
// bytes: the \n and the \" land in the committed turn.
|
||||
try h.respond(200, "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"const hi =\\n \\\"hi\\\";\"}}]}");
|
||||
// Deltas whose content carries JSON escapes decode into real bytes:
|
||||
// the \n and the \" land in the committed turn after [DONE].
|
||||
try h.complete("data: {\"choices\":[{\"delta\":{\"content\":\"const hi =\\n \\\"hi\\\";\"}}]}");
|
||||
try std.testing.expectEqual(@as(usize, 4), Bridge.model().turns.len);
|
||||
try std.testing.expectEqualStrings("const hi =\n \"hi\";", Bridge.model().turns[3].text);
|
||||
}
|
||||
|
||||
test "long streamed replies wrap inside the conversation instead of overflowing on x" {
|
||||
const h = try Harness.create();
|
||||
defer h.destroy();
|
||||
|
||||
const long_reply =
|
||||
"Every night, Lina left a cup of tea on her windowsill for the moon, " ++
|
||||
"because her grandmother had once said that kindness travels farther " ++
|
||||
"than footsteps and always finds its way home before morning.";
|
||||
const long_reply_event =
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"" ++ long_reply ++ "\"}}]}";
|
||||
|
||||
try h.say("tell me a story");
|
||||
try h.line(long_reply_event);
|
||||
try std.testing.expectEqualStrings(long_reply, Bridge.model().pendingReply);
|
||||
|
||||
const layout = try h.harness.runtime.canvasWidgetLayout(1, canvas_label);
|
||||
const conversation = layout.findById(h.findLabel("Conversation").?).?.frame.normalized();
|
||||
const reply_text = layout.findById(h.findId(.text, long_reply).?).?.frame.normalized();
|
||||
try std.testing.expect(reply_text.x >= conversation.x);
|
||||
try std.testing.expect(reply_text.maxX() <= conversation.maxX());
|
||||
try std.testing.expect(reply_text.width < conversation.width);
|
||||
try std.testing.expect(reply_text.height > 20);
|
||||
|
||||
try h.complete("data: {\"choices\":[{\"delta\":{\"content\":\" done\"}}]}");
|
||||
}
|
||||
|
||||
test "a streamed reply paints its visible tail beyond the paragraph page cap" {
|
||||
const h = try Harness.create();
|
||||
defer h.destroy();
|
||||
|
||||
const long_reply =
|
||||
("The lantern kept watch through the night.\n" ** 150) ++
|
||||
"TAIL_SENTINEL";
|
||||
const long_reply_event =
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"" ++
|
||||
("The lantern kept watch through the night.\\n" ** 150) ++
|
||||
"TAIL_SENTINEL\"}}]}";
|
||||
|
||||
try h.say("tell me a long story");
|
||||
try h.line(long_reply_event);
|
||||
try std.testing.expectEqualStrings(long_reply, Bridge.model().pendingReply);
|
||||
|
||||
// The chat's controlled scroll follows each streamed delta to the
|
||||
// bottom. The visible display list must therefore contain the final
|
||||
// response page, not just reserve its height beneath the first 128
|
||||
// painted visual lines.
|
||||
const layout = try h.harness.runtime.canvasWidgetLayout(1, canvas_label);
|
||||
var commands: [512]canvas.CanvasCommand = undefined;
|
||||
var builder = canvas.Builder.init(&commands);
|
||||
try canvas.emitWidgetLayout(&builder, layout, .{});
|
||||
var saw_tail = false;
|
||||
for (builder.displayList().commands) |command| {
|
||||
if (command == .draw_text and std.mem.indexOf(u8, command.draw_text.text, "TAIL_SENTINEL") != null) {
|
||||
saw_tail = true;
|
||||
}
|
||||
}
|
||||
try std.testing.expect(saw_tail);
|
||||
|
||||
try h.line("data: [DONE]");
|
||||
try h.finish(200);
|
||||
|
||||
const finished_layout = try h.harness.runtime.canvasWidgetLayout(1, canvas_label);
|
||||
const finished_reply = finished_layout.findById(h.findLabel("The model said").?).?.frame.normalized();
|
||||
const tail_padding = finished_layout.findById(h.findLabel("Conversation tail padding").?).?.frame.normalized();
|
||||
const prompt = finished_layout.findById(h.findLabel("Prompt composer").?).?.frame.normalized();
|
||||
try std.testing.expectEqual(@as(f32, 24), tail_padding.height);
|
||||
try std.testing.expect(finished_reply.maxY() <= tail_padding.y);
|
||||
try std.testing.expectApproxEqAbs(prompt.y, tail_padding.maxY(), 0.01);
|
||||
}
|
||||
|
||||
test "long visible history sends a recent suffix within the fetch body bound" {
|
||||
const h = try Harness.create();
|
||||
defer h.destroy();
|
||||
|
||||
// Each wire pair decodes to one quote. Two individually valid SSE
|
||||
// lines build a 40 KiB visible assistant turn which needs more than
|
||||
// 80 KiB when JSON escaping puts it into the next request.
|
||||
const escaped_quotes = "\\\"" ** (20 * 1024);
|
||||
const quote_event =
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"" ++
|
||||
escaped_quotes ++
|
||||
"\"}}]}";
|
||||
|
||||
try h.say("quote something");
|
||||
try h.line(quote_event);
|
||||
try h.line(quote_event);
|
||||
try h.line("data: [DONE]");
|
||||
try h.finish(200);
|
||||
try std.testing.expectEqual(@as(usize, 2), Bridge.model().turns.len);
|
||||
try std.testing.expectEqual(@as(usize, 40 * 1024), Bridge.model().turns[1].text.len);
|
||||
|
||||
// Full history remains committed and visible, while only the newest
|
||||
// user-led suffix rides the bounded provider request.
|
||||
try h.say("follow up");
|
||||
try std.testing.expectEqual(@as(usize, 3), Bridge.model().turns.len);
|
||||
try std.testing.expectEqual(@as(usize, 1), h.app_state.effects.pendingFetchCount());
|
||||
const request = h.app_state.effects.pendingFetchAt(0).?;
|
||||
try std.testing.expect(request.body.len <= 64 * 1024);
|
||||
try std.testing.expect(std.mem.indexOf(u8, request.body, "follow up") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, request.body, "quote something") == null);
|
||||
|
||||
try h.complete("data: {\"choices\":[{\"delta\":{\"content\":\"still here\"}}]}");
|
||||
}
|
||||
|
||||
test "the in-flight guard: a second send issues nothing and loses nothing" {
|
||||
const h = try Harness.create();
|
||||
defer h.destroy();
|
||||
@@ -420,14 +739,30 @@ test "the in-flight guard: a second send issues nothing and loses nothing" {
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
try std.testing.expectEqual(@as(usize, 1), Bridge.model().turns.len);
|
||||
|
||||
// Type a follow-up while the request is out: the Send button is
|
||||
// DISABLED (the markup binds the same guard update enforces — a
|
||||
// click is impossible), and even the journaled command path issues
|
||||
// nothing — no second fetch, no phantom turn, and the draft
|
||||
// SURVIVES (a blocked send loses nothing).
|
||||
// Model choice stays live while this request streams. The already
|
||||
// issued request keeps its captured model; the new choice is state
|
||||
// for the next prompt only.
|
||||
const selector = findWidgetByLabel(h.app_state.tree.?.root, "Model selector").?;
|
||||
try std.testing.expect(!selector.state.disabled);
|
||||
try h.click(selector.id);
|
||||
const models = findWidgetByLabel(h.app_state.tree.?.root, "Models").?;
|
||||
const sol = findWidgetKindText(models, .menu_item, sol_model_label).?;
|
||||
try h.click(sol.id);
|
||||
try std.testing.expectEqualStrings(sol_model_name, Bridge.model().modelName);
|
||||
const in_flight = fx.pendingFetchAt(0).?;
|
||||
try std.testing.expect(std.mem.indexOf(u8, in_flight.body, "\"model\":\"test-model\"") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, in_flight.body, sol_model_name) == null);
|
||||
|
||||
// Type a follow-up while the request is out: the Send action has
|
||||
// become an enabled Stop action, while the update guard still makes
|
||||
// even a journaled send a no-op — no second fetch, no phantom turn,
|
||||
// and the draft SURVIVES (a blocked send loses nothing).
|
||||
try h.click(h.findLabel("Message").?);
|
||||
try h.textInput("eager follow-up");
|
||||
try std.testing.expect(findWidgetByLabel(h.app_state.tree.?.root, "Send message").?.state.disabled);
|
||||
try std.testing.expect(findWidgetByLabel(h.app_state.tree.?.root, "Send message") == null);
|
||||
const stop_button = findWidgetByLabel(h.app_state.tree.?.root, "Stop generating").?;
|
||||
try std.testing.expect(!stop_button.state.disabled);
|
||||
try std.testing.expectEqualStrings("x", stop_button.icon);
|
||||
try h.menu("chat.send");
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
try std.testing.expectEqual(@as(usize, 1), Bridge.model().turns.len);
|
||||
@@ -439,17 +774,71 @@ test "the in-flight guard: a second send issues nothing and loses nothing" {
|
||||
|
||||
// The reply lands and the guard lifts: the surviving draft sends
|
||||
// through the journaled command path.
|
||||
try h.respond(200, "{\"choices\":[{\"message\":{\"content\":\"answer\"}}]}");
|
||||
try h.complete("data: {\"choices\":[{\"delta\":{\"content\":\"answer\"}}]}");
|
||||
try std.testing.expectEqual(@as(usize, 2), Bridge.model().turns.len);
|
||||
try h.menu("chat.send");
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
try h.respond(200, "{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}");
|
||||
try std.testing.expect(std.mem.indexOf(u8, fx.pendingFetchAt(0).?.body, "\"model\":\"openai/gpt-5.6-sol\"") != null);
|
||||
try h.complete("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}");
|
||||
try std.testing.expectEqual(@as(usize, 4), Bridge.model().turns.len);
|
||||
try h.click(h.findLabel("Message").?);
|
||||
try h.textInput(" ");
|
||||
try h.click(h.findLabel("Send message").?);
|
||||
try std.testing.expectEqual(@as(usize, 0), fx.pendingFetchCount());
|
||||
try std.testing.expectEqual(@as(usize, 4), Bridge.model().turns.len);
|
||||
|
||||
// Shift+Enter inserts a real LF in the textarea. General whitespace
|
||||
// trimming must still reject the draft instead of issuing a blank turn.
|
||||
try h.click(h.findLabel("Message").?);
|
||||
try h.keyDownShift("enter");
|
||||
{
|
||||
var draft_arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer draft_arena.deinit();
|
||||
try std.testing.expectEqualStrings(" \n", Bridge.model().draftText(draft_arena.allocator()));
|
||||
}
|
||||
try h.click(h.findLabel("Send message").?);
|
||||
try std.testing.expectEqual(@as(usize, 0), fx.pendingFetchCount());
|
||||
try std.testing.expectEqual(@as(usize, 4), Bridge.model().turns.len);
|
||||
|
||||
// New chat remains available during a live reply: it cancels the
|
||||
// keyed stream and resets both history and the composer immediately.
|
||||
try h.say("cancel this reply");
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
try h.click(h.findLabel("New chat").?);
|
||||
try std.testing.expectEqual(@as(usize, 0), fx.pendingFetchCount());
|
||||
try std.testing.expectEqual(@as(usize, 0), Bridge.model().turns.len);
|
||||
try std.testing.expect(Bridge.model().phase == .idle);
|
||||
{
|
||||
var draft_arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer draft_arena.deinit();
|
||||
try std.testing.expectEqualStrings("", Bridge.model().draftText(draft_arena.allocator()));
|
||||
}
|
||||
}
|
||||
|
||||
test "Stop cancels the live stream immediately and keeps its partial response" {
|
||||
const h = try Harness.create();
|
||||
defer h.destroy();
|
||||
const fx = &h.app_state.effects;
|
||||
|
||||
try h.say("write a long answer");
|
||||
try h.line("data: {\"choices\":[{\"delta\":{\"content\":\"A partial answer\"}}]}");
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
try std.testing.expectEqualStrings("A partial answer", Bridge.model().pendingReply);
|
||||
|
||||
const stop_button = findWidgetByLabel(h.app_state.tree.?.root, "Stop generating").?;
|
||||
try h.click(stop_button.id);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), fx.pendingFetchCount());
|
||||
try std.testing.expect(Bridge.model().phase == .idle);
|
||||
try std.testing.expectEqual(@as(usize, 2), Bridge.model().turns.len);
|
||||
try std.testing.expect(Bridge.model().turns[0].role == .user);
|
||||
try std.testing.expect(Bridge.model().turns[1].role == .assistant);
|
||||
try std.testing.expectEqualStrings("A partial answer", Bridge.model().turns[1].text);
|
||||
try std.testing.expectEqual(@as(usize, 0), Bridge.model().pendingReply.len);
|
||||
try std.testing.expect(h.hasText("A partial answer"));
|
||||
try std.testing.expect(findWidgetByLabel(h.app_state.tree.?.root, "Stop generating") == null);
|
||||
try std.testing.expect(!findWidgetByLabel(h.app_state.tree.?.root, "Send message").?.state.disabled);
|
||||
try std.testing.expectEqual(h.findLabel("Message").?, h.focusedWidgetId());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- failure paths
|
||||
@@ -459,10 +848,11 @@ test "every failure shape lands in the failed state with a reason and keeps the
|
||||
defer h.destroy();
|
||||
const fx = &h.app_state.effects;
|
||||
|
||||
// A 500 with a chat-completions error body: the endpoint's own
|
||||
// A 500 with a chat-completions error line: the Gateway's own
|
||||
// error.message surfaces, and the unanswered user turn stays.
|
||||
try h.say("hello?");
|
||||
try h.respond(500, "{ \"error\": { \"message\": \"model overloaded\", \"type\": \"server_error\" } }");
|
||||
try h.line("{ \"error\": { \"message\": \"model overloaded\", \"type\": \"server_error\" } }");
|
||||
try h.finish(500);
|
||||
try std.testing.expect(Bridge.model().phase == .failed);
|
||||
try std.testing.expectEqualStrings("model overloaded", Bridge.model().failReason);
|
||||
try std.testing.expectEqual(@as(usize, 1), Bridge.model().turns.len);
|
||||
@@ -476,23 +866,25 @@ test "every failure shape lands in the failed state with a reason and keeps the
|
||||
try std.testing.expect(Bridge.model().phase == .sending);
|
||||
try std.testing.expectEqual(@as(usize, 1), fx.pendingFetchCount());
|
||||
try std.testing.expectEqual(@as(usize, 1), Bridge.model().turns.len);
|
||||
try h.respond(200, "{\"choices\":[{\"message\":{\"content\":\"hi\"}}]}");
|
||||
try h.complete("data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}");
|
||||
try std.testing.expect(Bridge.model().phase == .idle);
|
||||
try std.testing.expectEqual(@as(usize, 2), Bridge.model().turns.len);
|
||||
|
||||
// A 200 whose body is not a chat completion is failed, never a
|
||||
// half-parsed conversation.
|
||||
// A 200 stream that ends without the protocol's [DONE] marker is
|
||||
// failed, never a silently accepted partial conversation.
|
||||
try h.say("again");
|
||||
try h.respond(200, "<html>gateway error</html>");
|
||||
try h.line("<html>gateway error</html>");
|
||||
try h.finish(200);
|
||||
try std.testing.expect(Bridge.model().phase == .failed);
|
||||
try std.testing.expectEqualStrings("the response did not parse as a chat completion", Bridge.model().failReason);
|
||||
try std.testing.expectEqualStrings("the response stream ended before [DONE]", Bridge.model().failReason);
|
||||
try std.testing.expectEqual(@as(usize, 3), Bridge.model().turns.len);
|
||||
|
||||
// A non-200 without an error body reads as its status line.
|
||||
try h.click(h.findLabel("Retry request").?);
|
||||
try h.respond(404, "not found");
|
||||
try h.line("not found");
|
||||
try h.finish(404);
|
||||
try std.testing.expect(Bridge.model().phase == .failed);
|
||||
try std.testing.expectEqualStrings("the endpoint answered HTTP 404", Bridge.model().failReason);
|
||||
try std.testing.expectEqualStrings("Vercel AI Gateway answered HTTP 404", Bridge.model().failReason);
|
||||
|
||||
// A transport failure surfaces the engine's machine-readable reason.
|
||||
try h.click(h.findLabel("Retry request").?);
|
||||
@@ -502,12 +894,15 @@ test "every failure shape lands in the failed state with a reason and keeps the
|
||||
try std.testing.expectEqualStrings("timed_out", Bridge.model().failReason);
|
||||
try std.testing.expect(h.hasText("timed_out"));
|
||||
|
||||
// The history survived the whole gauntlet, and Clear resets it.
|
||||
// The history survived the whole gauntlet, and the titlebar's New
|
||||
// chat button resets it through the real markup press path.
|
||||
try std.testing.expectEqual(@as(usize, 3), Bridge.model().turns.len);
|
||||
try h.menu("chat.clear");
|
||||
const new_chat = findWidgetByLabel(h.app_state.tree.?.root, "New chat").?;
|
||||
try std.testing.expect(!new_chat.state.disabled);
|
||||
try h.click(new_chat.id);
|
||||
try std.testing.expectEqual(@as(usize, 0), Bridge.model().turns.len);
|
||||
try std.testing.expect(Bridge.model().phase == .idle);
|
||||
try std.testing.expect(h.hasText("Ask anything"));
|
||||
try std.testing.expect(h.hasText("What can I help with?"));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- record / replay
|
||||
@@ -567,7 +962,7 @@ fn recordSession(buffer: *JournalBuffer) !ChatSnapshot {
|
||||
const recorder = try std.heap.page_allocator.create(runtime_ns.SessionRecorder);
|
||||
defer std.heap.page_allocator.destroy(recorder);
|
||||
recorder.* = runtime_ns.SessionRecorder.init(buffer.sink());
|
||||
recorder.begin(.{ .platform_name = "test", .app_name = "ai-chat-ts-e2e", .window_width = 760, .window_height = 640 });
|
||||
recorder.begin(.{ .platform_name = "test", .app_name = "chatbot-e2e", .window_width = 760, .window_height = 640 });
|
||||
|
||||
const h = try Harness.createConfigured(recorder, &configured_env);
|
||||
defer h.destroy();
|
||||
@@ -575,7 +970,7 @@ fn recordSession(buffer: *JournalBuffer) !ChatSnapshot {
|
||||
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
||||
try h.menu("chat.say.one");
|
||||
try h.menu("chat.send");
|
||||
try h.respond(200, "{\"choices\":[{\"message\":{\"content\":\"Hi there!\"}}]}");
|
||||
try h.complete("data: {\"choices\":[{\"delta\":{\"content\":\"Hi there!\"}}]}");
|
||||
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
||||
|
||||
// A transport failure and its retry are part of the recorded truth.
|
||||
@@ -584,7 +979,7 @@ fn recordSession(buffer: *JournalBuffer) !ChatSnapshot {
|
||||
try h.app_state.effects.feedResponseOutcome(chat_fetch_key, .timed_out, 0, "");
|
||||
try h.wake();
|
||||
try h.menu("chat.retry");
|
||||
try h.respond(200, "{\"choices\":[{\"message\":{\"content\":\"Certainly.\"}}]}");
|
||||
try h.complete("data: {\"choices\":[{\"delta\":{\"content\":\"Certainly.\"}}]}");
|
||||
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
||||
|
||||
recorder.finish();
|
||||
@@ -601,6 +996,10 @@ fn replayWithEnv(journal_bytes: []const u8, env_values: []const Adapter.EnvValue
|
||||
});
|
||||
defer harness.destroy(std.testing.allocator);
|
||||
harness.null_platform.gpu_surfaces = true;
|
||||
// Replay must expose the same shell geometry and native-scroll
|
||||
// capabilities as recording; both affect the rendered checkpoint.
|
||||
harness.null_platform.window_chrome = test_window_chrome;
|
||||
harness.null_platform.gpu_surface_scroll_drivers = true;
|
||||
const app_state = try std.testing.allocator.create(App);
|
||||
defer std.testing.allocator.destroy(app_state);
|
||||
app_state.* = Adapter.init(std.heap.page_allocator, .{ .env_values = env_values }, appOptions());
|
||||
@@ -631,7 +1030,7 @@ test "a recorded conversation replays byte-identically with zero host calls" {
|
||||
|
||||
// Replay into a fresh app with the variables UNSET: the journaled
|
||||
// fetch results feed the re-issued (parked) requests in recorded
|
||||
// order — no endpoint, no network, no host calls — and the launch
|
||||
// order — no network and no host calls — and the launch
|
||||
// configuration feeds from the journal's `.env` records, so replay
|
||||
// performs ZERO env reads. A machine with none of the variables set
|
||||
// replays the recorded conversation byte-identically.
|
||||
@@ -639,29 +1038,27 @@ test "a recorded conversation replays byte-identically with zero host calls" {
|
||||
const report = try replayWithEnv(buffer.journalBytes(), &.{});
|
||||
try std.testing.expect(report.ok());
|
||||
try std.testing.expect(report.events_replayed > 0);
|
||||
// The journaled effect results are the three fetch answers (two
|
||||
// successes and the timeout) plus the three env deliveries.
|
||||
try std.testing.expectEqual(@as(u64, 6), report.effects_fed);
|
||||
// Each success is a delta line, [DONE], and terminal; add the
|
||||
// timeout plus the two env deliveries.
|
||||
try std.testing.expectEqual(@as(u64, 9), report.effects_fed);
|
||||
try std.testing.expectEqualDeep(recorded, ChatSnapshot.take());
|
||||
}
|
||||
|
||||
// Replay again with the variables CHANGED at replay launch: the
|
||||
// journaled values still win (the recorded endpoint/model/key drive
|
||||
// journaled values still win (the recorded model/key drive
|
||||
// the replay, never the replay launch's environment) — the recorded
|
||||
// truth is immune to the machine it replays on.
|
||||
{
|
||||
const changed_env = [_]Adapter.EnvValue{
|
||||
.{ .msg = "endpoint_set", .value = "http://other.test/v2/chat" },
|
||||
.{ .msg = "model_set", .value = "other-model" },
|
||||
.{ .msg = "key_set", .value = "other-key" },
|
||||
};
|
||||
const report = try replayWithEnv(buffer.journalBytes(), &changed_env);
|
||||
try std.testing.expect(report.ok());
|
||||
try std.testing.expectEqual(@as(u64, 6), report.effects_fed);
|
||||
try std.testing.expectEqual(@as(u64, 9), report.effects_fed);
|
||||
try std.testing.expectEqualDeep(recorded, ChatSnapshot.take());
|
||||
// The recorded configuration, not the changed launch's, is the
|
||||
// replayed model's.
|
||||
try std.testing.expectEqualStrings(test_model_name, Bridge.model().modelName);
|
||||
try std.testing.expectEqualStrings(test_endpoint, Bridge.model().endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// v2 effect record — an init-command request, keyed replace and cancel,
|
||||
// a fire-and-forget bytes command, Cmd.now, a model-gated timer
|
||||
// subscription, the named engine ops (readFile/writeFile/fetch/
|
||||
// clipboard) plus the one-shot delay, and the streaming ops (a real
|
||||
// subprocess spawn with line/exit routing and mid-stream cancel, and
|
||||
// the audio and video event streams with their control verbs).
|
||||
// clipboard) plus the one-shot delay, and the streaming ops (a line-
|
||||
// streamed fetch, a real subprocess spawn with line/exit routing and
|
||||
// mid-stream cancel, and the audio and video event streams with their
|
||||
// control verbs).
|
||||
// Transpiled at build time by the repo's own transpiler (never committed
|
||||
// as Zig) and driven through the real runtime by
|
||||
// tests/ts-core/host_e2e_tests.zig.
|
||||
@@ -103,6 +104,9 @@ export type Msg =
|
||||
| { readonly kind: "wrote" }
|
||||
| { readonly kind: "get" }
|
||||
| { readonly kind: "fetched"; readonly status: number; readonly body: Uint8Array }
|
||||
| { readonly kind: "stream" }
|
||||
| { readonly kind: "streamed"; readonly status: number }
|
||||
| { readonly kind: "cancel_stream" }
|
||||
| { readonly kind: "share" }
|
||||
| { readonly kind: "paste" }
|
||||
| { readonly kind: "later" }
|
||||
@@ -234,6 +238,18 @@ export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
|
||||
];
|
||||
case "fetched":
|
||||
return [{ ...model, code: msg.status, status: msg.body }, Cmd.none];
|
||||
case "stream":
|
||||
return [
|
||||
model,
|
||||
Cmd.fetch(
|
||||
{ url: asciiBytes("https://status.test/events"), method: "POST", headers: { accept: "text/event-stream" }, body: model.status, timeoutMs: 60000, maxLineBytes: 65536 },
|
||||
{ key: "events", line: "lined", ok: "streamed", err: "failed" },
|
||||
),
|
||||
];
|
||||
case "streamed":
|
||||
return [{ ...model, code: msg.status }, Cmd.none];
|
||||
case "cancel_stream":
|
||||
return [model, Cmd.cancel("events")];
|
||||
case "share":
|
||||
return [model, Cmd.clipboardWrite(model.status)];
|
||||
case "paste":
|
||||
|
||||
@@ -66,6 +66,8 @@ fn e2eCommand(name: []const u8) ?fixture.Msg {
|
||||
if (std.mem.eql(u8, name, "core.save")) return .save;
|
||||
if (std.mem.eql(u8, name, "core.load")) return .load;
|
||||
if (std.mem.eql(u8, name, "core.get")) return .get;
|
||||
if (std.mem.eql(u8, name, "core.stream")) return .stream;
|
||||
if (std.mem.eql(u8, name, "core.cancelstream")) return .cancel_stream;
|
||||
if (std.mem.eql(u8, name, "core.share")) return .share;
|
||||
if (std.mem.eql(u8, name, "core.paste")) return .paste;
|
||||
if (std.mem.eql(u8, name, "core.later")) return .later;
|
||||
@@ -576,6 +578,47 @@ test "fetch parks on the engine and routes the { status, body } record and err r
|
||||
try std.testing.expectEqual(@as(f64, 404), Bridge.model().code);
|
||||
}
|
||||
|
||||
test "streaming fetch routes response lines and one terminal status through the compiled TypeScript core" {
|
||||
HostStub.reset();
|
||||
const h = try Harness.createFake();
|
||||
defer h.destroy();
|
||||
const fx = &h.app_state.effects;
|
||||
|
||||
try fx.feedHostResult(status_request_key, true, "prompt");
|
||||
try h.wake();
|
||||
try h.menu("core.stream");
|
||||
|
||||
const request = fx.pendingFetchAt(0).?;
|
||||
try std.testing.expectEqual(job_spawn_key, request.key);
|
||||
try std.testing.expectEqual(runtime_ns.FetchResponseMode.stream, request.response);
|
||||
try std.testing.expectEqual(@as(usize, 65_536), request.max_line_bytes);
|
||||
try std.testing.expectEqual(std.http.Method.POST, request.method);
|
||||
try std.testing.expectEqualStrings("https://status.test/events", request.url);
|
||||
try std.testing.expectEqualStrings("prompt", request.body);
|
||||
try std.testing.expectEqualStrings("text/event-stream", request.headers[0].value);
|
||||
|
||||
try fx.feedLine(job_spawn_key, "data: first");
|
||||
try h.wake();
|
||||
try std.testing.expectEqual(@as(@TypeOf(Bridge.model().lines), 1), Bridge.model().lines);
|
||||
try std.testing.expectEqualStrings("data: first", Bridge.model().lastLine);
|
||||
|
||||
try fx.feedLine(job_spawn_key, "data: second");
|
||||
try fx.feedResponse(job_spawn_key, 206, "ignored");
|
||||
try h.wake();
|
||||
try std.testing.expectEqual(@as(@TypeOf(Bridge.model().lines), 2), Bridge.model().lines);
|
||||
try std.testing.expectEqualStrings("data: second", Bridge.model().lastLine);
|
||||
try std.testing.expectEqual(@as(@TypeOf(Bridge.model().code), 206), Bridge.model().code);
|
||||
try std.testing.expectEqual(@as(i64, 0), Bridge.model().failures);
|
||||
|
||||
// A second stream can reuse the key after the terminal; cancellation is
|
||||
// loud for streams and routes the ordinary err arm.
|
||||
try h.menu("core.stream");
|
||||
try h.menu("core.cancelstream");
|
||||
try h.wake();
|
||||
try std.testing.expectEqual(@as(i64, 1), Bridge.model().failures);
|
||||
try std.testing.expectEqualStrings("cancelled", Bridge.model().lastErr);
|
||||
}
|
||||
|
||||
test "a delay arms a real platform timer, fires once, re-arms on re-issue, and cancels" {
|
||||
HostStub.reset();
|
||||
const h = try Harness.create();
|
||||
|
||||
@@ -2732,6 +2732,29 @@ const FacadeEmitter = struct {
|
||||
\\ nscfWU8(sink, 0x1f);
|
||||
\\ nscfWF64(sink, cmd.key);
|
||||
\\ return;
|
||||
\\ case "fetch_stream": {
|
||||
\\ nscfWU8(sink, 0x20);
|
||||
\\ nscfWShortText(sink, cmd.key);
|
||||
\\ nscfWU8(sink, nscfTagOf(cmd.lineKind));
|
||||
\\ nscfWU8(sink, nscfTagOf(cmd.okKind));
|
||||
\\ nscfWU8(sink, nscfTagOf(cmd.errKind));
|
||||
\\ nscfWU8(sink, nscfMemberIndex(nscfFetchMethods, cmd.method, "fetch method"));
|
||||
\\ nscfWU32(sink, cmd.timeoutMs);
|
||||
\\ nscfWU32(sink, cmd.maxLineBytes);
|
||||
\\ nscfWBytes(sink, cmd.url);
|
||||
\\ if (cmd.headers.length > 255) {
|
||||
\\ nscfTrap("a fetch carries over 255 headers — the wire's header block cannot carry it");
|
||||
\\ }
|
||||
\\ nscfWU8(sink, cmd.headers.length);
|
||||
\\ for (let i = 0; i < cmd.headers.length; i++) {
|
||||
\\ const header = cmd.headers[i]!;
|
||||
\\ nscfWShortText(sink, header.name);
|
||||
\\ const value = header.value;
|
||||
\\ nscfWBytes(sink, typeof value === "string" ? nscfTextBytes(value) : value);
|
||||
\\ }
|
||||
\\ nscfWBytes(sink, cmd.body);
|
||||
\\ return;
|
||||
\\ }
|
||||
\\ case "batch":
|
||||
\\ for (let i = 0; i < cmd.cmds.length; i++) {
|
||||
\\ nscfEncodeCmd(sink, cmd.cmds[i]!);
|
||||
|
||||
@@ -126,10 +126,12 @@ pub const attribute_docs = [_]Doc{
|
||||
.{ .name = "global-key", .doc = "Parent-independent identity: ids survive reparenting between containers." },
|
||||
.{ .name = "role", .doc = "Accessibility role (listitem, treeitem, button, ...). treeitem also makes the row part of its tree's roving keyboard focus set." },
|
||||
.{ .name = "min-width", .doc = "Width floor (plain number) without width's definite max: the element may grow past it but never shrink below. On split panes it bounds the divider drag." },
|
||||
.{ .name = "max-width", .doc = "Width ceiling (plain number) without width's definite min: the element still shrinks with a narrow parent. Use a growing child inside a centered row for a responsive content column." },
|
||||
.{ .name = "expanded", .doc = "Tree rows (role=\"treeitem\"): disclosure state (true/false or a {binding}). Omit on leaves; expanded rows collapse on Left, collapsed ones expand on Right, both through on-toggle - the model owns the state." },
|
||||
.{ .name = "tree-level", .doc = "Flat sibling rows with role=\"treeitem\": one-based logical depth used by Left/Right to resolve parents and first children. Omit it when tree rows are structurally nested." },
|
||||
.{ .name = "label", .doc = "Accessible name; when set it REPLACES the element's text as the announced name - screen readers and automation snapshots see the label, never the text it shadows." },
|
||||
.{ .name = "autofocus", .doc = "Focusable controls only: moves keyboard focus to the element when it mounts or when the value turns on (edge-triggered - holding it true never re-steals focus). The TEA way to focus an editor on create." },
|
||||
.{ .name = "submit-on-enter", .doc = "textarea only: true makes plain Enter dispatch on-submit while Shift+Enter inserts a newline; Cmd/Ctrl+Enter still submits. False or absent keeps the multiline default where Enter inserts and submission uses the primary chord." },
|
||||
.{ .name = "icon", .doc = "button, toggle-button, list-item, menu-item: vector icon drawn inline (buttons/toggle-buttons before the label, list/menu items as a leading slot): a built-in name (comptime-validated against canvas.icons.known_icon_names, e.g. save, plus, refresh-cw), an app-registered app:<name>, or one {binding} resolving to such a name. Icon-only buttons when the content is empty — add a label. One hit target, one enabled/disabled tint." },
|
||||
.{ .name = "icon-placement", .doc = "Icon slot side on label-bearing buttons/toggle-buttons: leading (default) draws the icon before the label, trailing after it — the next-page chevron. Icon-only buttons center the glyph regardless." },
|
||||
.{ .name = "window-drag", .doc = "Marks the element as a window-drag surface (the hidden-titlebar pattern): pressing its background - or plain text/icons inside - moves the window; double-click zooms per the OS convention. Buttons and other press-claiming children inside stay clickable. macOS-only; elsewhere the press is dead space." },
|
||||
|
||||
Reference in New Issue
Block a user