@symbo.ls/workspace-model
v3.14.4
Published
The Symbols workspace as plain data and a headless runtime — apps, the demo and SDK backends, view and dialog models, snapshot/click/input — shared by the terminal TUI and the browser Agent mode
Maintainers
Keywords
Readme
@symbo.ls/workspace-model
The Symbols workspace as plain data and a headless runtime: the apps, the
demo and SDK backends, the view and dialog models, and one runtime with
snapshot() / handle() / click() / input() / viewport().
No renderer and no Node built-ins: it runs in a browser and in Node. Two
renderers draw the same snapshot():
- the terminal TUI (
smbls/packages/tui) draws it into cells; - the workspace Agent mode draws it as DOMQL.
Because both draw one snapshot, the two screens cannot differ.
import { createWorkspaceRuntime, createDemoBackend, createSdkBackend, keyFromDom, isControlKey } from '@symbo.ls/workspace-model'Create a runtime
// Demo data, in memory (no login, no network):
const data = createDemoBackend()
// Live data over the shell's own signed-in SDK. With `session`, nothing is
// resolved and the SDK context is not changed. `sdk.getService(name)` may
// return the service (Node) or a Promise of it (the browser shell):
const data = await createSdkBackend({
sdk, // an initialized @symbo.ls/sdk instance
session: { user, org, workspace } // raw server records or normalized ones
})
const rt = createWorkspaceRuntime({
data,
host: {
open: (url) => window.open(url, '_blank', 'noopener'),
switchWorkspace: (id, w) => shell.switchWorkspace(id) // then rt.reset(newSession)
// edit, exit, suspend: terminal only — leave them out in the browser
},
options: { shellUrl: 'https://my.symbols.app', lang: 'ka' } // lang: the shell's root.lang
})
rt.start() // or rt.start({ app: 'tasks' })
rt.setLang('en') // the shell's language changed: every word followsHost contract
Every member is optional in the browser.
| Member | Meaning |
|---|---|
| edit(text, { ext, label }) → Promise<string\|null> | An external editor (the terminal's $EDITOR). Without it, an editor form field is a multiline value you edit in place (a <textarea>), and an app's ctx.edit() opens a one-field form. |
| open(url) | o / "Open in the browser". The runtime shows a toast either way. |
| exit(code) | q / ctrl+c. Without it, the keys do nothing, and help, the palette and the footer hints do not list them. |
| suspend() | ctrl+z (terminal). Without it, help does not list ctrl+z. |
| switchWorkspace(id, w) | The shell owns workspace switching. When set, the w picker calls it and does NOT switch the backend. The shell then calls rt.reset(session) or makes a new runtime. |
Options: channel (status text when not production), shellUrl (base for
o), noTimers (tests), schedule(fn) (your own "run on a later task"),
lang ('en' default, 'ka'; a region tag such as 'ka-GE' counts as its
language, anything else is English — see "Language").
The API
| Method | Does |
|---|---|
| snapshot() | The whole screen as plain data (below). Call it in the render handler. |
| on('render', fn) → off() | The change signal. Coalesced: one call per task. |
| handle(ev) | A key { type: 'key', name, ch, ctrl, meta, shift } or a paste { type: 'paste', text }. keyFromDom(e) builds the key from a KeyboardEvent. |
| click(payload, { double }) | A click on any snapshot node that has click. Pass that object as is. |
| input(target, value, { caret }) | A text control changed. target is the node's target; caret is selectionStart (UTF-16). Also moves the model focus there. Returns true when a control took it. |
| viewport({ contentRows, contentCols, docRows, top }) | Sizes for the page and scroll keys, in rows (below). |
| focus('sidebar' \| 'content' \| 'ask') | Move the model focus. |
| setLang(lang) → boolean, lang | Switch the language live (true when it changed); lang is the one in use. |
| reset(session?) | A new workspace: drop the cached views, start on Home. |
| start(route?), stop(), idle() | Start; stop the timers; resolve when no load runs (tests). |
| navigate({ app, view, params }), ask(text), runAction(app, action) | Direct calls. |
| state | The live state. Read snapshot() instead; state is for tests. |
Keys in a browser
The model is the source of truth; the DOM types natively (IME, paste, Georgian, emoji).
- No text control has the focus: forward every key —
const ev = keyFromDom(e); if (ev) { e.preventDefault(); rt.handle(ev) }. - A text control has the focus: let the control type. Forward only
isControlKey(ev)keys (enter, escape, tab, up, down, page up/down, ctrl/cmd+K, ctrl/cmd+S). Send the text withinput()on everyinputevent. keyFromDomreturnsnullfor ctrl/cmd + C V X Z Y A (they stay with the browser) and for IME composition.- Cmd counts as ctrl (
Cmd+K= palette,Cmd+S= save a form; callpreventDefault). Alt ismeta(word jumps).
snapshot()
{
host: 'sunfield-farms.workspace', // header, left
title: 'Tasks — Wheat harvest, field 7', // app label (+ record title on a detail view)
app: 'tasks',
route: { app, view, params },
canGoBack: true,
tabs: [{ key, label, active, click: { tab } }], // views of the app; [] when < 2
status: { kind, tone, text },
// kind 'toast' tone 'success' | 'error' | 'info' (also in `toast`)
// kind 'busy' a spinner; animate with `spin` (a counter)
// kind 'channel' tone 'accent' | 'warning' ('demo', 'staging', …)
// kind 'user' tone 'faint' the user's name
spin: 12, busy: false,
focus: 'sidebar' | 'content' | 'ask',
session: { user, org, workspace, workspaces },
sidebar: {
label: 'Agro', // workspace name
focused, cursor, // cursor = item.index under the keyboard
groups: [{ key, label, // label null for the first group
items: [{ key, label, icon, hint, active, badge, count, index, click: { app } }] }]
},
footer: {
label: 'ASK AI', focused, click: { ask: true },
input: { target: 'ask', value, caret, placeholder, focused },
hints: [['⏎', 'send'], ['esc', 'cancel']] // [key, label] pairs
},
content: null | { // the open view of `route`
status: 'idle' | 'loading' | 'refreshing' | 'ready' | 'error',
error: null | { code, message, hint },
type: 'board' | 'list' | 'detail' | 'dashboard' | 'chat' | 'assistant',
def: { key, label, type, columns, empty, hidden },
data, // the view's data (shapes in src/views/*.js)
ui, // the view's live ui state
focused, // content has the keys (no dialog open)
typing, // a text control in the view is open
view // renderer-ready parts, per type (below); null while loading/error without data
},
assistant: { turns: [{ role: 'user' | 'assistant', text, status, error }], streaming },
overlays: [ … ], // bottom → top; the last one has the keys
toast: null | { text, kind }
}Show Loading <label>… when status === 'loading' and there is no data.
Show error.message + error.hint + "r retry esc back" when
status === 'error' and there is no data.
content.view per type
| Type | view |
|---|---|
| board | { columns: [{ key, label, count, empty, active, cards: [{ id, title, left, right, rightToken, selected, click: { col, row }, item }] }] } |
| dashboard | { greeting, subtitle, tiles: [{ label, value, hint, state, progress }], errors, columns } — columns as on a board |
| list | { columns: [{ key, label, width \| flex, align }], filtering, filter: null \| input(target 'filter'), rows: [{ group, key, index } \| { id, index, cells, dot, people, strong, selected, click: { sel }, item }] } |
| detail | { top } — the record is content.data: { title, meta, fields: [[label, value]], body (markdown), sections: [{ title, empty, items: [{ author, time, text }] }] } |
| chat | { channels: [{ id, label, topic, selected, click: { select: { channel } } }], channel, messages: [{ id, author, mine, time, text }], writing, composer: input(target 'composer'), writeClick: { write: true } } |
| assistant | { top, follow, suggestions } — the turns are snapshot().assistant.turns (markdown) |
left on a card and state on a tile are small visuals:
{ kind: 'dot', state: 'idle'|'live'|'done'|'warn'|'error' },
{ kind: 'progress', value: 0..1 }, { kind: 'people', names },
{ kind: 'spark', values, state }, { kind: 'ticks', values },
{ kind: 'text', text, token }.
Selection: selected marks the keyboard selection. A single click selects
(click(payload)); a double click opens (click(payload, { double: true })).
overlays[]
| kind | Fields |
|---|---|
| picker (palette, workspaces) | title, width, emptyText, query: input(target 'query'), sel, items: [{ index, label, hint, token, selected, enabled, click: { pick } }] |
| form | title, submitLabel, index, submitFocused, submitClick: { submit: true }, editing, externalEditor, fields: [field] |
| confirm | title, message, danger, yes, no, choice (0 = yes), yesClick: { ok: true }, noClick: { ok: false } |
| help | title ('Keys'), sections: [{ title, rows: [[key, label]] }] |
A form field: { key, label, type, index, focused, required, error, target: 'field:<key>', click: { field }, value } plus, per type:
text→input(an input node, targetfield:<key>);select→options: [{ label, value }],choice(index).input('field:<key>', optionValue)picks one;editor→multiline: true. In the browser this is a<textarea>:input('field:<key>', text).
Esc closes a dialog (handle), and so does a click on a payload that
settles it (pick, submit, ok).
Input nodes
{ target, value, caret, placeholder, focused } // caret: UTF-16 offset, for setSelectionRangeTargets: ask · query (the top dialog's filter) · field:<key> · composer
(chat) · filter (a list). The renderer must set value and the caret from
the snapshot after each render — ↑/↓ in the ask bar walk the history,
a send clears the composer.
(ref on an input node and ui on a dialog or the content are the live
model objects. The terminal keeps its scroll memory there; a DOM renderer
ignores them.)
viewport()
Page and scroll keys read sizes the terminal knows from its cells. A DOM renderer gives them in rows of its line height:
rt.viewport({
contentRows: Math.floor(contentEl.clientHeight / lineHeight), // PgUp/PgDn page
docRows: Math.ceil(scrollEl.scrollHeight / lineHeight), // detail / assistant length
top: Math.round(scrollEl.scrollTop / lineHeight) // after a native scroll
})
// and scroll to view.top × lineHeight when it changes (j/k, g/G, PgDn)contentRows sticks (every view opened later gets it). docRows and top
apply to the open view. Lists and boards need no sizes: their page keys
move 10 rows / 5 cards.
Language
Every word the package writes speaks the runtime's lang: app names, view
tabs, column heads, footer hints, the help, the palette, dialog titles, form
labels and errors, empty states, toasts, error messages, the Home greeting and
dates. English ('en', the default) reads exactly as before; Georgian is
'ka'.
setLang(lang)switches at once: every word a snapshot computes follows on the next render. A loaded view holds the words of the language it loaded in, so the open view loads again and the others load again when they show. An open dialog keeps its words until it closes.- Data stays as written: records, names, a server's own error text,
identifiers (keys, emails, paths, versions),
host, format notation (YYYY-MM-DD). Server values with a known meaning — roles, board columns that still carry their standard label, task states and types, notification and room kinds, project types and statuses, note privacy, workspace types, search surfaces — show in the language; an unknown value shows as sent. - Dates name the month and weekday in the language (
პარასკევი, 23 ოქტომბერი), from the catalog rather thanIntl: Chrome ships no Georgian locale data. Numbers keep theen-USformat, as the shell does. - Ask AI sends
context.uiLanguage(the shell's assistant does the same); the server answers in it when the prompt itself has no language. - Errors:
describeError()andtuiError(code, message, hint, { key, hintKey, vars })carry the catalog key next to the English. The snapshot shows{ code, message, hint }in the language (localizeError(t, d)).
import { createT, CATALOGS, LANGS, tOf } from '@symbo.ls/workspace-model'
const t = createT('ka')
t('palette.goTo', { app: 'შენიშვნები' }) // 'გადასვლა: შენიშვნები'
t.relTime(updatedAt) // '5 წთ'
t.date(Date.now(), { weekday: 'long', day: 'numeric', month: 'long' })
Object.keys(CATALOGS.ka) // the same keys as CATALOGS.enThe catalogs live in src/i18n/en.js (the source) and src/i18n/ka.js.
App code reads words through its context: const t = tOf(ctx) — the
runtime's t, or English where a context has none (the terminal's command
line). An action's done(result, item, ctx) and a field's validate(value,
ctx) get the context for that reason. An app object keeps its English
(label, hint, view, column, action, field and option labels); the screen
shows them by a stable key — app.<app>.label, app.<app>.view.<view>,
app.<app>.col.<column>, app.<app>.action.<action>.field.<field>, … — and
only while the app still says what the catalog's English says (t.or), so a
changed label never shows a stale translation. A new app or string needs its
keys in both catalogs; the tests list what is missing.
A DOM renderer loop
import { createWorkspaceRuntime, createSdkBackend, keyFromDom, isControlKey } from '@symbo.ls/workspace-model'
const rt = createWorkspaceRuntime({ data: await createSdkBackend({ sdk, session }), host })
const paint = () => {
const s = rt.snapshot()
// DOMQL: put `s` into state; components read it. For example:
// Sidebar ← s.sidebar.groups[].items[] onClick: () => rt.click(item.click)
// Tabs ← s.tabs onClick: () => rt.click(tab.click)
// Content ← s.content (switch on .type; .view holds the rows/cards)
// onClick: (e) => rt.click(card.click, { double: e.detail > 1 })
// AskBar ← s.footer.input onInput: (e) => rt.input('ask', e.target.value, { caret: e.target.selectionStart })
// Dialogs ← s.overlays (picker query → input('query', …), fields → input(field.target, …))
// Status ← s.status / s.toast
el.state.update({ screen: s })
}
rt.on('render', paint)
window.addEventListener('keydown', (e) => {
const ev = keyFromDom(e)
if (!ev) return
const inText = e.target.matches?.('input, textarea, [contenteditable]')
if (inText && !isControlKey(ev)) return // the control types the text
e.preventDefault()
rt.handle(ev)
})
rt.start()
paint()Inside
src/
core/runtime.js createWorkspaceRuntime — state, keys, clicks, input, loads, actions, Ask AI, snapshot
core/overlays.js picker, form, confirm, help — models with key/describe/input
core/input.js the text input model (graphemes, readline keys, history)
core/keys.js keyFromDom, isControlKey
core/scheduler.js a portable "run on a later task" (MessageChannel)
core/errors.js one error shape: { code, message, hint } (+ the catalog key)
views/*.js board, list, detail, dashboard, chat, assistant — init/key/selected/clamp/describe
apps/*.js one plain object per app (README of packages/tui: "Add an app")
data/demo.js the in-memory farm (tests and --demo)
data/sdk.js createSdkBackend({ sdk, session }) + normalize
i18n/ createT, the catalogs (en.js, ka.js), dates, error words
util/ graphemes, initials, relTime, clock, dayMonthTests: npx vitest run packages/workspace-model from smbls/.
