streaming-ui-script · todo app ← Back to docs
JS demo · Query + Mutation + Script

A full todo app, persisted to localStorage

The UI is plain Streaming UI Script — input, filter buttons, list, bulk actions, footer. Reads go through a Query tool, writes through Mutations. A single Script listens for keyboard shortcuts (⌘+Enter to add) and exposes a keyboard hint badge.

Live preview

Add a few tasks, then refresh the page — they survive. The filter buttons mutate $filter declaratively; the list updates because list_todos re-runs whenever its args change.

UI Script source

Note how the script only takes care of the imperative pieces (focus, keyboard shortcut, status pill). State and data flow stay declarative.

$filter = "all"
$draft = ""
$ver = 0
$shortcut = "⌘ Enter to add"

data = Query("list_todos", {filter: $filter, ver: $ver}, {rows: [], total: 0, active: 0, done: 0})
add = Mutation("add_todo", {title: $draft})
toggle = Mutation("toggle_todo", {id: $toggleId})
remove = Mutation("delete_todo", {id: $deleteId})
clearDone = Mutation("clear_completed", {})
$toggleId = ""
$deleteId = ""

inputField = FormControl("New task", Input("draft", "What needs doing?", "text", null, $draft))
addBtn = Button("Add", Action([@Run(add), @Run(data), @Reset($draft)]), "primary")
form = Stack([inputField, addBtn], "row", "m", "end")

filterButtons = Buttons([
  Button("All " + "(" + data.total + ")",        Action([@Set($filter, "all")]),    $filter == "all" ? "primary" : "ghost",    "normal", "small"),
  Button("Active " + "(" + data.active + ")",    Action([@Set($filter, "active")]), $filter == "active" ? "primary" : "ghost", "normal", "small"),
  Button("Completed " + "(" + data.done + ")",   Action([@Set($filter, "done")]),   $filter == "done" ? "primary" : "ghost",   "normal", "small")
])

statusTag = Tag($shortcut, null, "sm", "neutral")

toggleBtn = @Each(data.rows, "t", Button(t.done ? "Done" : "Open", Action([@Set($toggleId, t.id), @Run(toggle), @Run(data)]), t.done ? "secondary" : "ghost", "normal", "small"))
deleteBtn = @Each(data.rows, "t", Button("Delete", Action([@Set($deleteId, t.id), @Run(remove), @Run(data)]), "ghost", "normal", "small"))
titleCell  = @Each(data.rows, "t", TextContent(t.title, t.done ? "small" : "body", t.done ? "muted" : null))
createdCell= @Each(data.rows, "t", TextContent(t.created, "small", "muted"))

tbl = Table([
  Col("",        toggleBtn),
  Col("Title",   titleCell),
  Col("Added",   createdCell),
  Col("",        deleteBtn)
])

empty = TextContent($filter == "done" ? "Nothing completed yet." : ($filter == "active" ? "All clear!" : "No tasks. Add your first one above."), "small", "muted")
list = @Count(data.rows) > 0 ? tbl : empty

footer = Stack([
  TextContent("" + data.active + " active · " + data.done + " done", "small", "muted"),
  Button("Clear completed", Action([@Run(clearDone), @Run(data)]), "ghost", "normal", "small")
], "row", "m", "center", "between")

shortcutScript = Script("shortcut", "const onKey = async (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); const draft = (ctx.state.get('draft') ?? '').trim(); if (!draft) return; await ctx.tools.add_todo({ title: draft }); if (ctx.signal.aborted) return; ctx.state.set('draft', ''); ctx.state.set('ver', (ctx.state.get('ver') ?? 0) + 1); } }; window.addEventListener('keydown', onKey); ctx.cleanup(() => window.removeEventListener('keydown', onKey));")

root = Stack([
  Card([CardHeader("Things to do", "Persisted to localStorage")]),
  Card([form]),
  Card([Stack([filterButtons, statusTag], "row", "m", "center", "between")]),
  Card([list]),
  Card([footer]),
  shortcutScript
])

The host wires the storage tools

Tools are plain functions — the LLM (or you, in this case) decides when to call them. Add localStorage persistence in one place and every Query / Mutation stays in sync.

const STORAGE_KEY = "rui-todo-app";
const load = () => JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
const save = (todos) => localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));

el.setTools({
  list_todos: ({ filter }) => {
    const todos = load();
    const visible = todos.filter((t) => (
      filter === "all" || (filter === "done" ? t.done : !t.done)
    ));
    return {
      rows: visible.map((t) => ({ ...t, created: relative(t.createdAt) })),
      total: todos.length,
      active: todos.filter((t) => !t.done).length,
      done: todos.filter((t) => t.done).length,
    };
  },
  add_todo:        ({ title }) => { ... },
  toggle_todo:     ({ id })    => { ... },
  delete_todo:     ({ id })    => { ... },
  clear_completed: ()          => { ... },
});