tuimark

Inline mode

Draw a screen as a live region below the shell's prompt, and print into the terminal's own scrollback above it with Commit.

By default Run() takes the whole terminal (the alternate screen). Inline mode draws the active screen as a live region below the cursor instead, and the host prints what it wants to keep into the terminal's own scrollback, above the region, with Commit. The user scrolls, selects, and copies that scrollback with the terminal's own means, as with any command-line tool. A chat client, an installer, and a prompt that asks for an approval are the typical users.

Only a <tui> without version runs inline; for a document that writes version it is the error tuimark: inline mode needs a <tui> without version, before the terminal is touched. Nothing in the markup changes: inline mode is a way to run a document.

Running inline

ui, _ := tuimark.Load("app.tui")
// ...Bind, On...
err := ui.RunWith(context.Background(), os.Stdout, tuimark.RunOptions{Inline: true})

tuimark run app.tui --inline does the same without Go code. Run and RunContext are never inline.

The region

The region is the active screen laid out at the terminal's width and at the height of its content, as a modal sized by its content is. It follows its content: it grows when a node appears (a composer taking a second line, an approval) and shrinks when it goes.

  • A height or max-height in cells written on the screen caps it, a min-height in cells raises it, and the terminal's rows are the last cap. The built-in height: 100% of screen does not apply.
  • @media reads the terminal's columns and rows, never the region's.
  • When the content is taller than the terminal, the children share the terminal's rows as a modal's children share its cap: an fr child and a scroll, list, or table take what the others leave, and what is still too tall is cut (L009). A transcript in a <scroll stick="bottom"> shows its end with the composer and the status in view.
  • A modal and an open option list are laid out against the region as against a screen of its size. The region does not grow for them.
  • The document's mouse is ignored: an inline session has no mouse.

Committing

ui.Commit(tuimark.Commit{Lines: []any{
    "You: hello",
    []any{map[string]any{"text": "ok", "class": "good"}},
}})
ui.Commit(tuimark.Commit{Template: "card", With: "last as m", Props: map[string]string{"who": "Claude"}})

A line is a string or an array of runs. It is styled by your stylesheet as a child of the active screen, wrapped at the terminal's width, and an LF in it starts a new row. A template commit prints the copy of a <template> laid out from column 0 at the terminal's width, with its ids dropped and its <slot/> empty: to commit a card whose content comes through its slot, commit a wrapper template that has none, after writing the item to a scratch path of the store.

A commit prints at once while your handler runs and is otherwise queued before the next frame; it is safe from any goroutine. A commit that fails prints nothing, and its error says why (commit: no <template id="T">, commit: line 1 is a number, not a string or an array of runs, and so on). Commits made before the session ends are printed before the region is erased, so a host that wants to keep its last state commits it before it quits.

Streaming a reply by blocks

A long reply is committed block by block. Each commit redraws the region once, so commit a finished paragraph, a list, or a fenced code block, never a token, and keep only the unfinished tail block in the region. The region shows the tail (live.blocks, one block of the kit's transcript schema) and the scrollback holds everything before it:

<col if="live.active" each="live.blocks as b" key="b.id" width="1fr" height="auto">
  <use template="k-block" with="b as blk"/>
</col>
<template id="commit-block">
  <col height="auto">
    <use template="k-block" with="cb as blk"/>
    <spacer style="height: 1" if="!cb.item"/>
  </col>
</template>

A block is {"id", "runs", …} with the optional flags code, head, quote, item, and rule; the runs of a markdown paragraph are strings and {"text", "class"} objects, and a code block keeps its line breaks and spaces (pre-wrap). Turning markdown into blocks is the host's work: examples/host-ts/markdown.ts is a reference markdownToBlocks(md, {streaming}) in TypeScript (paragraphs, headings, fenced code, lists, and quotes). Its streaming mode closes an open fence, leaves an unclosed ** as text, and holds back a last line that is only the start of a marker, so the tail never flashes a stray #.

The loop of the host, as examples/host-ts/stream.ts writes it:

for await (const chunk of model) {
  buf += chunk;
  const blocks = markdownToBlocks(buf, { streaming: true });
  // Every block before the last is finished: commit it, and show the last.
  for (const block of blocks.slice(committed, -1)) {
    await ui.batch([{ path: "commit_buf", value: block }]);
    await ui.commit({ template: "commit-block", with: "commit_buf as cb" });
    committed++;
  }
  await ui.batch([
    { path: "commit_buf", value: null },
    { path: "live.blocks", value: blocks.slice(-1) },
  ]);
}

A commit is printed before the frame that shows the write that follows it, so the commit and the write that removes the block from the region, sent together (in one handler, or in one read of the host protocol's fd 3), are drawn as one frame: the block is never on the screen twice. On a cancel or a quit the host commits the partial tail before it replies, since the region is erased on the way out. A copy reads its item through a with alias, so the host writes the block to a scratch path of the store (commit_buf) before the commit and clears it after.

A card with a slot

A committed copy's <slot/> stays empty, so a card whose content comes through its slot prints its header alone. To commit one, write a wrapper template without a slot whose body is the card's <use> with the content in place, reading an alias:

<template id="card" props="role"><text class="who">{$role}</text><slot/></template>
<template id="commit-msg" props="role">
  <use template="card" role="{$role}">
    <col each="msg.blocks as b" key="b.id"><text runs="b.runs"/></col>
  </use>
</template>

The host writes the item to commit_buf, commits commit-msg with with: "commit_buf as msg" and props: {"role": "assistant"}, and clears the path.

An approval inside the region

An approval, inline, is a block of the region in flow, not a modal, which the region would clip. A focused textarea takes every printable key before the keymap, so the approval's y and n rows would never fire while the composer has focus. Disable the composer while the approval shows, and scope the rows to a class the screen has meanwhile:

<screen id="inline" focus="#composer" class:approving="approval.open">
  <text if="approval.open" class="ask">{approval.title}? y approve · n deny</text>
  <textarea id="composer" bind="draft" disabled="approval.open" placeholder="Message the model" on:change="typed" on:submit="send"/>
</screen>
<keymap when="#inline.approving">
  <bind keys="y" action="approve"/>
  <bind keys="n,enter" action="deny"/>
  <bind keys="esc" action="deny"/>
</keymap>
<keymap>
  <bind keys="esc" action="cancel"/>
</keymap>

While approval.open is true nothing has focus, so the rows of the first keymap fire; when the host writes it back to false the composer takes focus again and typing works. The host can commit the diff the approval asks about while it waits for the answer, since a commit made while an event waits for its reply is printed at once. With the kit's k-composer the flag is composer.disabled.

Over the host protocol

tuimark host FILE --inline runs the session inline for a parent in any language, and spawnHost(file, { inline: true }) passes it. The parent's commit message is Commit:

const ui = await spawnHost("app.tui", { inline: true });
await ui.commit({ lines: ["You: hello"] });
await ui.commit({ template: "card", with: "last as m", props: { who: "Claude" } });

It is acked once its rows are written, a failure (commit: no <template id="T">, and so on) is acked ok: false with the error, and in a session that is not inline every commit is commit: not in inline mode. A commit made while an event waits for its reply is printed before the reply is sent, and one sent just before a reply with quit, even in the same write, stays above the cursor. A document that writes version is tuimark: host: inline mode needs a <tui> without version, exit 1, with nothing on fd 4. See the host protocol and the TypeScript client.

Checking an inline screen

Everything can be checked without a terminal:

tuimark dump app.tui --data sample.json --cols 80 --rows 24 --inline --format json
tuimark play app.tui --data sample.json --inline --frames --format json --script steps.ndjson

dump --inline has the region's height in rows and the terminal's in terminal_rows. play --inline takes the step {"commit": {"lines": ["a"]}} (commit:JSON in --input), lists the committed rows in scrollback, and each frame says in scrollback_rows how many rows were committed up to its step. A golden manifest entry takes "inline": true, and the MCP tools take inline.

Limits

Rows already committed belong to the terminal, which rewraps them or not when it is resized: Tuimark never repaints them, and a part of the region the terminal rewrapped before the redraw may stay in the scrollback. Tuimark wraps a committed line at the terminal's width, so a long line copied from the scrollback breaks where it wrapped; keep text meant to be copied whole (a command) shorter than the terminal.

There is no mouse in an inline session. A session killed with SIGKILL under a terminal that answered the Kitty keyboard query cannot pop the mode, so the shell reads CSI-u keys until the terminal is reset. A terminal without bracketed paste delivers a paste as keys, and each carriage return in it is an enter that submits a composer; and text a host shows that may hold tabs, a tool's output for one, goes in runs, not in a {path}, which removes a tab where run text makes it four spaces.

The examples/inline program is a small chat that commits what you send; examples/host-ts/inline.ts is a chat that streams a reply into the scrollback by blocks from a Bun parent.

Edit on GitHub

Last updated on

On this page