Fold thinking deltas into the turn's stream-ordered entries as a thinking entry, merged back-to-back the way the stream delivers them, instead of one flat string rendered above every call. Each thinking block now sits where it streamed — between the calls it precedes. Live turns keep their thinking expanded; finished turns render it collapsed.
rhema claw web
A small, hand-rolled web client for the claw (rhema-claw): a live
transcript of one session, the session inventory, and a settings area
(model providers, about). Nuxt 4 + Vue 3 + TypeScript, no component
library, no store. It is strictly one client of the claw's public
/v1 HTTP surface.
Run it against a locally running claw
rhema-claw # the daemon, on 127.0.0.1:7799
cd web/claw
npm install
npm run dev # http://localhost:3000
In dev, the server/middleware/claw-proxy.ts middleware forwards
/v1 and /healthz to http://127.0.0.1:7799 (NUXT_CLAW_UPSTREAM
overrides), so the browser sees a same-origin API and CORS never enters
the picture. If the claw is not running, the page still loads: the
down/401/stream banners tell you what is wrong.
Token
When the claw config sets token:, every /v1 route wants
Authorization: Bearer <token>. Give the UI a token either way:
- the environment:
NUXT_PUBLIC_CLAW_TOKEN=... npm run dev(runtime configclawToken), or - the inline field in the 401 banner; it is stored in
localStorage.
/healthz is open either way, which is how the header dot stays honest
when the token is wrong.
Build
npm run build # nuxt generate -> .output/public, fully static
npm run typecheck # vue-tsc over the whole app
npm run build produces a static SPA in .output/public, the artifact
an ingress serves. The ingress owns routing: same-origin /v1 and
/healthz to the claw. runtimeConfig.public.clawBase can point the
client at another base, but the claw sends no CORS headers, so
cross-origin use needs a proxy, not a different origin.
The one-consumer principle
The claw's /v1 API is the contract (crates/rhema-claw/src/http.rs):
sessions, messages, events, providers. This client uses only public
routes, speaks the wire shapes the Rust side serializes, and adds
nothing server-side. UI needs never justify private endpoints; they
justify claw API changes, argued in the Rust tree.
Two consequences worth knowing before changing this code:
- The daemon owns the conversation.
POST .../messagesis fire-and-forget (202); the reply streams back onGET /v1/sessions/{key}/events. The transcript here is a mirror of the stream, and the stream opens with the claw's replay of the session's recorded turns before the live feed — a reload and every reconnect reset the mirror and rebuild from the replay, so the transcript survives the browser, not just the claw. - SSE goes over fetch, not
EventSource. The claw's auth reads only theAuthorization: Bearerheader (http.rsrequire_auth); it has no query-param fallback, andEventSourcecannot set headers. SouseClaw().subscribeEventsholds aGETwith the auth header, reads the body as aReadableStream, and parsesdata:frames by hand (composables/useClaw.ts). Drops retry with backoff; the hub'slaggedcomments surface as notices.
What the transcript does with the stream
- Long pastes travel as chips. A paste over three lines (the TUI's
PASTE_INLINE_LINES) never lands in the composer; it stages as a droppable chip (▣1 42L) and embeds into the sent text as U+FFFC-payload-U+FFFC — the same recordrhema-core'spaste::embedkeeps — so the transcript renders each embed back as a chip in place of the payload. An unpaired mark is literal text. - The agent can ask. A
consent_requestevent renders as an inline card: kindsecretgets a password input plus submit, kindphonegets Approve/Deny, and both carry a deny link (DELETE /v1/consents/{id}). The card flips when the answer lands and again on the stream'sconsent_resolvedecho; a reconnect's replay rebuilds the cards, so nothing persists client-side. - Attachments render inline. An
attachmentevent becomes a card — media glyph, file name, B/KB/MB size, caption. Images fetch the bearer-guarded bytes (GET /v1/attachments/{id}, blob → object URL, like the identity images) and render capped at 240px; code and file cards download on click. Object URLs cache per id, so a replay never refetches. - Sessions are created in place. The sidebar's
+form postsPOST /v1/sessionswith a client-validated name ([A-Za-z0-9._-]{1,64}), switches the chat to the new key, and lets 400/409 land in the error banner. The phone client's status keys (phone:…) stay out of the inventory; the full list stays on the wire.
Layout
The shell is an icon rail plus two views, switched by a tiny hash
router (#/chat, #/settings/agent/general, #/settings/agent/ accounts, #/settings/providers, #/settings/about), no
vue-router. The chat view is the session inventory plus the live
transcript; the settings view is a searchable nav plus pages. The
agent's identity (name, mark, avatar) loads
once and renders everywhere — rail, sidebar, chat, window title —
through AgentAvatar, which owns the precedence: avatar image, then
mark or initials. Settings pages stage edits into a local draft and
apply them with an explicit Save (PUT /v1/providers/active answers
the new pair only; the client carries the provider list forward).
The providers page also manages the roster itself: the add form renders
from the addable kinds the claw serves; each kind's fields carry
{name, required}, required marking what the daemon refuses the login
without (llama.cpp needs neither of its fields, it serves keyless on a
default url); the form demands only those, marks the rest optional, and
takes an optional instance name; unnamed, the id is the kind. A login
is PUT /v1/providers/{id}/credentials: apply immediately,
credentials persist; roster rows are backend instances (id, the kind
unless the login named it, plus the credential's source
badge, env-backed kinds return on restart after a removal), and the
remove button (DELETE /v1/providers/{id}) refuses the last backend
claw-side.
app/
app.vue the shell: rail, banners, view switch
assets/css/main.css the Jadeite tokens (matches the TUI's
palette.rs DARK) + card/badge primitives
components/
AppRail.vue the icon rail: views, claw pulse
AppBadge.vue the status/tag pill
AppCallout.vue the toned inline note (info/ok/warn/error)
AppMarkdown.vue agent replies as markdown (markdown-it,
raw HTML escaped, styles in main.css .md)
ClawBanner.vue the one error/notice banner surface
ChatPanel.vue transcript: sent lines (paste chips),
streamed turns as bash shell lines and
glyph rows, consent + attachment
cards; the growing composer; agent
replies render through AppMarkdown
SessionsSidebar.vue conversable dispatch keys (phone status
keys hidden), interactive first; the
+ form creates a session
PageHeader.vue title + subtitle + page actions
SettingCard.vue one titled card of rows
SettingRow.vue name + description + control
SettingsNav.vue searchable settings nav
StatusDot.vue one honest state dot
settings/
ProvidersPage.vue the backend switch: draft → Save/Discard
AboutPage.vue identity, stack, the routes spoken
composables/
useClaw.ts the typed API client + fetch-based SSE
useTranscript.ts folds TurnEvents into renderable turn
blocks; owns the paste-embed text form
server/
middleware/claw-proxy.ts dev-only same-origin proxy to a running claw