feat(rhema-code): add the colors setting with four palettes

The stored `colors` key names a palette — rhema (the default,
Jadeite), aquamarine, black-and-white, or dracula — and the
Appearance page of /settings cycles it live, like the theme and
icon toggles beside it. Each palette carries a dark and a light
appearance under the existing `theme` key; dracula's light side is
derived, the official palette ships only dark.

The appearance decision moves off token comparison: Theme now
answers is_dark() from the ground/text luminance, so palettes
beyond the original two get the right per-profile fallback tones.
This commit is contained in:
vhaudiquet committed 2026-10-17 04:28:31 +08:00
commit 81da26b955
159 files changed
+50628

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
// The agent's face at one size. Two voices: `initials` (the chat, the
// sidebar, the rail's bottom — the avatar image when there is one, the
// initials when not) and `mark` (the rail's brand slot — the avatar
// image when there is one, the mark or emoji, ῥ by default). The
// precedence lives here and nowhere else.
const props = withDefaults(
defineProps<{
variant?: 'initials' | 'mark'
/** The uploaded avatar's object URL, when the claw has one. */
avatarUrl?: string | null
/** The glyph or emoji; the mark voice defaults to ῥ. */
mark?: string | null
/** The initials voice's fallback, first letter of the name or R. */
initials?: string | null
size?: number
}>(),
{
variant: 'initials',
avatarUrl: null,
mark: null,
initials: null,
size: 32,
},
)
const glyph = computed(() =>
props.variant === 'mark'
? props.mark?.trim() || 'ῥ'
: props.initials?.trim() || 'R',
)
</script>
<template>
<img
v-if="avatarUrl"
:src="avatarUrl"
:style="{ width: `${size}px`, height: `${size}px` }"
class="agent-avatar"
alt=""
/>
<span
v-else
class="agent-avatar agent-square"
:style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.5)}px` }"
aria-hidden="true"
>{{ glyph }}</span>
</template>
<style scoped>
.agent-avatar {
display: grid;
place-items: center;
flex: none;
border-radius: 25%;
object-fit: cover;
}
.agent-square {
background: var(--accent);
color: var(--on-accent);
font-weight: 700;
line-height: 1;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
// The pill: one small status or tag word. Machine spellings (session
// keys, model ids) stay mono via the global `.badge` voice.
defineProps<{
variant?: 'dim' | 'accent' | 'ok' | 'warn' | 'error'
}>()
</script>
<template>
<span class="badge" :class="variant"><slot /></span>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
// The inline callout: a bordered note with a tone — the "restart
// required"/"changes not applied" voice from the designs. One per
// situation, inside the page flow, dismissible by the caller.
defineProps<{
variant: 'info' | 'ok' | 'warn' | 'error'
title?: string
dismissible?: boolean
}>()
defineEmits<{ dismiss: [] }>()
const marks: Record<string, string> = {
info: 'i',
ok: '✓',
warn: '↺',
error: '✗',
}
</script>
<template>
<div class="callout" :class="variant" role="status">
<span class="mark" aria-hidden="true">{{ marks[variant] }}</span>
<div class="body">
<strong v-if="title" class="title">{{ title }}</strong>
<div class="text"><slot /></div>
</div>
<button v-if="dismissible" class="ghost dismiss" title="dismiss" @click="$emit('dismiss')">
×
</button>
</div>
</template>
<style scoped>
.callout {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 11px 14px;
border: 1px solid var(--border);
border-left: 3px solid var(--info);
border-radius: 8px;
background: color-mix(in srgb, var(--info) 7%, var(--surface));
}
.callout.ok {
border-left-color: var(--ok);
background: color-mix(in srgb, var(--ok) 7%, var(--surface));
}
.callout.warn {
border-left-color: var(--warn);
background: color-mix(in srgb, var(--warn) 7%, var(--surface));
}
.callout.error {
border-left-color: var(--error);
background: color-mix(in srgb, var(--error) 7%, var(--surface));
}
.mark {
flex: none;
width: 18px;
text-align: center;
font-weight: 700;
color: var(--info);
}
.ok .mark {
color: var(--ok);
}
.warn .mark {
color: var(--warn);
}
.error .mark {
color: var(--error);
}
.body {
flex: 1;
min-width: 0;
}
.title {
display: block;
color: var(--text);
}
.text {
color: var(--text2);
overflow-wrap: anywhere;
}
.dismiss {
padding: 0 6px;
font-size: 14px;
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
// Agent replies are markdown; this mounts the rendered HTML. The parser
// keeps raw HTML in the source escaped (html stays false) and refuses
// javascript: link targets, so v-html only ever receives generated
// markup.
import MarkdownIt from 'markdown-it'
import type { RendererRule } from 'markdown-it'
const props = defineProps<{
text: string
/** While true, a blinking cursor trails the last rendered block. */
streaming?: boolean
}>()
const md = new MarkdownIt({ html: false, linkify: true })
// Rendered links leave the dashboard; open them in a new tab so the
// transcript and its event stream survive the click.
const defaultLinkOpen: RendererRule =
md.renderer.rules.link_open ??
((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options))
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx]
if (token) {
token.attrSet('target', '_blank')
token.attrSet('rel', 'noopener noreferrer')
}
return defaultLinkOpen(tokens, idx, options, env, self)
}
const html = computed(() => md.render(props.text))
</script>
<template>
<div class="md" :class="{ streaming }" v-html="html" />
</template>
+105
View File
@@ -0,0 +1,105 @@
<script setup lang="ts">
// The one modal: a centered panel over a dimmed backdrop. Escape and a
// backdrop click close it; the panel stops the click from beneath. The
// caller owns open state and what the actions do.
import { onUnmounted, watch } from 'vue'
const props = defineProps<{
open: boolean
title: string
}>()
const emit = defineEmits<{ close: [] }>()
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && props.open) emit('close')
}
watch(
() => props.open,
(open) => {
if (!import.meta.client) return
if (open) window.addEventListener('keydown', onKeydown)
else window.removeEventListener('keydown', onKeydown)
},
{ immediate: true },
)
onUnmounted(() => {
if (import.meta.client) window.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<Teleport to="body">
<div v-if="open" class="overlay" @click.self="emit('close')">
<div class="panel" role="dialog" aria-modal="true" :aria-label="title">
<header class="head">
<strong class="title">{{ title }}</strong>
<button class="ghost dismiss" title="close" @click="emit('close')">×</button>
</header>
<div class="body">
<slot />
</div>
<footer v-if="$slots.actions" class="actions">
<slot name="actions" />
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
background: color-mix(in srgb, var(--sunken) 70%, transparent);
}
.panel {
width: 100%;
max-width: 520px;
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: 0 18px 48px color-mix(in srgb, var(--sunken) 80%, transparent);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.title {
font-size: 15px;
}
.dismiss {
padding: 0 6px;
font-size: 15px;
}
.body {
padding: 16px;
overflow-y: auto;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
}
</style>
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
// The icon rail: the leftmost strip every view shares — the brand mark,
// one button per view, and the claw's pulse at the bottom. The active
// view is highlighted; navigation intent flows up, the shell decides.
defineProps<{
view: 'chat' | 'settings'
/** null while /healthz has not answered yet. */
clawUp: boolean | null
identity?: { mark: string | null; initials: string | null; name: string | null } | null
avatarUrl?: string | null
}>()
defineEmits<{ navigate: [view: 'chat' | 'settings'] }>()
</script>
<template>
<nav class="rail">
<AgentAvatar
variant="mark"
:avatar-url="avatarUrl"
:mark="identity?.mark"
:size="36"
class="mark"
title="rhema, the spoken word"
/>
<button
class="rail-btn"
:class="{ active: view === 'chat' }"
title="Chat"
aria-label="Chat"
@click="$emit('navigate', 'chat')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 11.5a7.5 7.5 0 0 1-7.5 7.5H8l-4.5 3V11.5a7.5 7.5 0 0 1 7.5-7.5h2.5a7.5 7.5 0 0 1 7.5 7.5Z" />
</svg>
</button>
<button
class="rail-btn"
:class="{ active: view === 'settings' }"
title="Settings"
aria-label="Settings"
@click="$emit('navigate', 'settings')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
<div class="spacer" />
<span
class="pulse"
:title="clawUp === null ? 'health unknown' : clawUp ? '/healthz ok' : '/healthz failing'"
>
<span class="dot" :class="{ up: clawUp === true, down: clawUp === false }" aria-hidden="true" />
</span>
<AgentAvatar
:avatar-url="avatarUrl"
:initials="identity?.initials ?? identity?.name?.slice(0, 1)"
:size="30"
class="avatar"
title="rhema"
/>
</nav>
</template>
<style scoped>
.rail {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 56px;
flex: none;
padding: 12px 0;
background: var(--sunken);
border-right: 1px solid var(--border);
}
.mark {
margin-bottom: 10px;
}
.rail-btn {
display: grid;
place-items: center;
width: 36px;
height: 36px;
padding: 0;
border-radius: 9px;
border-color: transparent;
background: none;
color: var(--text3);
}
.rail-btn svg {
width: 19px;
height: 19px;
}
.rail-btn:hover:not(:disabled) {
background: var(--elevated);
color: var(--text);
}
.rail-btn.active {
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent-text);
}
.spacer {
flex: 1;
}
.dot {
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text3);
}
.dot.up {
background: var(--ok);
}
.dot.down {
background: var(--error);
}
.avatar {
margin-top: 8px;
}
</style>
+685
View File
@@ -0,0 +1,685 @@
<script setup lang="ts">
// The transcript: sent lines and streamed turns, the turn in flight at
// the bottom. The claw owns the conversation — this renders what the
// event stream delivered, nothing more. The header names the session
// and hides the dispatch internals behind an info modal; the composer
// chips mirror live claw state; the backend chip jumps to settings.
import {
PASTE_INLINE_LINES,
chipLabel,
embedPastes,
lineCount,
normalizePaste,
pasteParts,
} from '~/composables/useTranscript'
import type { ClawSession, ConsentAnswer, StreamStatus } from '~/composables/useClaw'
import type { PastePart, TranscriptItem, TurnBlock } from '~/composables/useTranscript'
const props = defineProps<{
items: TranscriptItem[]
live: TurnBlock | null
activeKey: string
/** Only `interactive:*` keys accept messages (the claw 400s the rest). */
canSend: boolean
busy: boolean
session: ClawSession | null
activeBackend: { provider: string; model: string } | null
streamStatus: StreamStatus
identity?: { name: string | null; mark: string | null; initials: string | null } | null
avatarUrl?: string | null
}>()
const agentName = computed(() => props.identity?.name?.trim() || 'rhema')
const emit = defineEmits<{
send: [text: string]
openProviders: []
consentAnswer: [id: string, answer: ConsentAnswer]
consentDeny: [id: string]
downloadAttachment: [id: string, name: string]
}>()
const draft = ref('')
const scroller = ref<HTMLElement | null>(null)
/** Pin to the bottom only while the reader is already there. */
const pinned = ref(true)
function onScroll(): void {
const el = scroller.value
if (!el) return
pinned.value = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}
watch(
() => [props.items.length, props.live?.reply.length, props.live?.thinking.length, props.live?.entries.length],
async () => {
if (!pinned.value) return
await nextTick()
const el = scroller.value
if (el) el.scrollTop = el.scrollHeight
},
)
function submit(): void {
const text = draft.value.trim()
if (!text || !props.canSend || props.busy) return
emit('send', embedPastes(text, pastes.value))
draft.value = ''
pastes.value = []
pinned.value = true
void nextTick(resizePrompt)
}
// --- the composer -----------------------------------------------------
const prompt = ref<HTMLTextAreaElement | null>(null)
/** The composer grows to this many rows, then scrolls instead. */
const MAX_PROMPT_ROWS = 10
/** Height follows the content; the CSS max-height clamps the growth. */
function resizePrompt(): void {
const el = prompt.value
if (!el) return
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}
/** Enter submits, shift+enter is a newline, ctrl/cmd+enter submits. */
function onPromptKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submit()
}
}
// --- pasted attachments ----------------------------------------------
// A paste over PASTE_INLINE_LINES lines rides as a chip: the payload
// never lands in the box, it embeds into the sent text on submit —
// the same record the TUI keeps.
const pastes = ref<string[]>([])
function onPaste(event: ClipboardEvent): void {
const transfer = event.clipboardData
if (!transfer) return
const text = transfer.getData('text/plain')
if (text !== '') {
if (absorbPaste(text)) event.preventDefault()
return
}
// A file paste reads as its text when it claims a textual type; the
// rest the composer ignores.
const file = transfer.files[0]
if (!file || !file.type.startsWith('text/')) return
event.preventDefault()
void file.text().then((text) => {
if (!absorbPaste(text)) insertInline(text)
})
}
/** A long paste becomes one chip; whether it did is the answer. */
function absorbPaste(text: string): boolean {
const normalized = normalizePaste(text)
if (lineCount(normalized) <= PASTE_INLINE_LINES) return false
pastes.value.push(normalized)
return true
}
/** A short file paste joins the draft where the caret sits. */
function insertInline(text: string): void {
const el = prompt.value
if (el && !el.disabled) {
el.setRangeText(text, el.selectionStart ?? el.value.length, el.selectionEnd ?? el.value.length, 'end')
draft.value = el.value
} else {
draft.value += text
}
resizePrompt()
}
/** One render piece of a user message: literal text or a paste chip. */
type MessageSegment = PastePart | { kind: 'chip'; label: string }
/** The message's render pieces: literal text and resolved chips, the
* chips' one-based ids counted across the message. */
function messageSegments(text: string): MessageSegment[] {
const segments: MessageSegment[] = []
let chip = 0
for (const part of pasteParts(text)) {
if (part.kind === 'text') {
if (part.text !== '') segments.push(part)
} else {
segments.push({ kind: 'chip', label: chipLabel(part.text, chip++) })
}
}
return segments
}
const displayName = computed(() => props.activeKey.replace(/^interactive:/, ''))
// The dispatch internals live behind the info modal, not in the header.
const infoOpen = ref(false)
const infoRows = computed(() => [
{ label: 'state', value: props.session?.state ?? 'unknown' },
{ label: 'revision', value: props.session ? String(props.session.revision) : '—' },
{ label: 'inbox depth', value: props.session ? String(props.session.inbox_depth) : '—' },
{ label: 'last outcome', value: props.session?.last_outcome ?? '—' },
{ label: 'key', value: props.activeKey },
])
const streamLabel = computed(() =>
props.streamStatus === 'open' ? 'live' : props.streamStatus,
)
function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
if (status === 'open') return 'ok'
if (status === 'reconnecting') return 'error'
return 'warn'
}
</script>
<template>
<section class="chat">
<header class="head">
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="34" />
<div class="text">
<h2>{{ displayName }}</h2>
</div>
<span class="stream-status" :title="`event stream: ${streamStatus}`">
<StatusDot :tone="streamTone(streamStatus)" />
{{ streamLabel }}
</span>
<button class="ghost info" title="session info" @click="infoOpen = true">ⓘ</button>
</header>
<AppModal :open="infoOpen" title="Session info" @close="infoOpen = false">
<dl class="info-rows">
<div v-for="row in infoRows" :key="row.label" class="info-row">
<dt class="label">{{ row.label }}</dt>
<dd class="mono">{{ row.value }}</dd>
</div>
</dl>
</AppModal>
<div ref="scroller" class="stream-area" @scroll.passive="onScroll">
<p v-if="items.length === 0 && !live" class="empty">
nothing streamed yet; messages post to the claw and replies arrive here live
</p>
<template v-for="item in items" :key="item.id">
<div v-if="item.kind === 'sent'" class="msg">
<div class="avatar user" aria-hidden="true">·</div>
<div class="msg-body">
<div class="who">you</div>
<div class="reply plain">
<template v-for="(segment, index) in messageSegments(item.text)" :key="index">
<span v-if="segment.kind === 'chip'" class="badge paste-chip">{{ segment.label }}</span>
<template v-else>{{ segment.text }}</template>
</template>
</div>
</div>
</div>
<div v-else-if="item.kind === 'turn'" class="msg">
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="32" />
<div class="msg-body">
<div class="who">{{ agentName }}</div>
<details v-if="item.turn.thinking" class="thinking">
<summary>thinking</summary>
<pre>{{ item.turn.thinking }}</pre>
</details>
<TurnEntries
:entries="item.turn.entries"
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
@consent-deny="(id) => emit('consentDeny', id)"
@download-attachment="(id, name) => emit('downloadAttachment', id, name)"
/>
<AppMarkdown v-if="item.turn.reply" :text="item.turn.reply" class="reply" />
<div
v-if="item.turn.outcome"
class="outcome mono"
:class="{ failed: item.turn.outcome === 'failed' }"
>
{{ item.turn.outcome }}
</div>
</div>
</div>
<div v-else class="msg" :class="item.kind">
<div class="msg-body">
<TurnEntries
:entries="[item.kind === 'consent' ? { kind: 'consent', consent: item.consent } : { kind: 'attachment', attachment: item.attachment }]"
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
@consent-deny="(id) => emit('consentDeny', id)"
@download-attachment="(id, name) => emit('downloadAttachment', id, name)"
/>
</div>
</div>
</template>
<div v-if="live" class="msg live">
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="32" />
<div class="msg-body">
<div class="who">{{ agentName }}</div>
<details v-if="live.thinking" class="thinking" open>
<summary>thinking</summary>
<pre>{{ live.thinking }}</pre>
</details>
<TurnEntries
:entries="live.entries"
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
@consent-deny="(id) => emit('consentDeny', id)"
@download-attachment="(id, name) => emit('downloadAttachment', id, name)"
/>
<AppMarkdown v-if="live.reply" :text="live.reply" streaming class="reply" />
</div>
</div>
</div>
<footer class="composer">
<div class="chips">
<span class="badge">{{ activeKey }}</span>
<button
class="badge chip-btn"
title="switch the model backend in settings"
:disabled="!activeBackend"
@click="emit('openProviders')"
>
{{ activeBackend ? `${activeBackend.provider} · ${activeBackend.model}` : 'no backend' }}
</button>
</div>
<div v-if="pastes.length > 0" class="paste-chips">
<span v-for="(paste, index) in pastes" :key="index" class="badge paste-chip">
{{ chipLabel(paste, index) }}
<button class="chip-x" :title="`drop paste ${index + 1}`" @click="pastes.splice(index, 1)">
×
</button>
</span>
</div>
<div class="input-row">
<textarea
ref="prompt"
v-model="draft"
rows="1"
:placeholder="canSend ? `Message ${agentName}…` : 'only interactive:* sessions accept messages'"
:disabled="!canSend"
@keydown="onPromptKeydown"
@paste="onPaste"
@input="resizePrompt"
/>
<button class="primary send" :disabled="!canSend || busy || draft.trim() === ''" title="send" @click="submit">
↑
</button>
</div>
</footer>
</section>
</template>
<style scoped>
.chat {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
flex: 1;
background: var(--ground);
}
.head {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.avatar {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex: none;
border-radius: 10px;
font-weight: 700;
}
.avatar.user {
background: var(--elevated);
border: 1px solid var(--border-strong);
color: var(--text2);
}
.text {
flex: 1;
min-width: 0;
}
.text h2 {
margin: 0;
font-size: 15px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.head .info {
flex: none;
padding: 0 6px;
font-size: 14px;
}
.info-rows {
margin: 0;
display: flex;
flex-direction: column;
}
.info-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 7px 0;
}
.info-row + .info-row {
border-top: 1px solid var(--border);
}
.info-row dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
text-align: right;
}
.stream-status {
display: flex;
align-items: center;
gap: 6px;
color: var(--text3);
font-size: 12px;
flex: none;
}
.stream-area {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 24px;
}
.empty {
color: var(--text3);
padding: 24px;
}
.msg {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.msg-body {
flex: 1;
min-width: 0;
}
.who {
font-weight: 700;
font-size: 13px;
margin-bottom: 2px;
}
.live .who {
color: var(--accent-text);
}
.thinking {
margin-bottom: 8px;
color: var(--text3);
}
.thinking summary {
cursor: pointer;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.thinking pre {
margin: 6px 0 0;
padding: 8px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text3);
font-style: italic;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.reply {
color: var(--text);
overflow-wrap: anywhere;
}
/* User lines are verbatim input; agent replies render markdown (see
main.css .md) and wrap by its rules instead. */
.plain {
white-space: pre-wrap;
}
.outcome {
margin-top: 6px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.outcome.failed {
color: var(--error);
}
/* A consent card: the agent asking its human something — distinct
from a tool row, toned by its resolution. */
.consent-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--warn);
border-radius: 6px;
}
.consent-card.ok {
border-left-color: var(--ok);
}
.consent-card.denied {
border-left-color: var(--error);
}
.consent-head {
display: flex;
align-items: center;
gap: 8px;
}
.consent-summary {
flex: 1;
min-width: 0;
font-size: 13px;
}
.denied .consent-summary {
color: var(--text3);
}
.consent-state {
flex: none;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ok);
}
.denied .consent-state {
color: var(--error);
}
.consent-detail {
margin: 0;
color: var(--text2);
font-size: 12.5px;
overflow-wrap: anywhere;
}
.consent-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.consent-actions input {
flex: 1;
min-width: 160px;
}
/* An attachment card: the agent handing a file over — head row names
it, the image (when there is one) rides under it, capped so a
screenshot cannot take the stream. */
.attachment-card {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 480px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.attachment-head {
display: flex;
align-items: center;
gap: 8px;
}
.attachment-glyph {
flex: none;
color: var(--text3);
}
.attachment-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.attachment-size {
flex: none;
color: var(--text3);
font-size: 11px;
}
.attachment-image {
max-width: 100%;
max-height: 240px;
align-self: flex-start;
border: 1px solid var(--border);
border-radius: 8px;
}
.attachment-caption {
margin: 0;
color: var(--text3);
font-size: 12.5px;
overflow-wrap: anywhere;
}
.composer {
border-top: 1px solid var(--border);
background: var(--surface);
padding: 10px 16px 14px;
}
.chips {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
min-width: 0;
overflow: hidden;
}
.chip-btn {
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
}
.chip-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent-text);
}
.paste-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
/* One embedded paste, standing in for its payload — composer staging
and transcript rendering share the shape. */
.paste-chip {
gap: 2px;
padding-right: 4px;
background: var(--elevated);
}
.chip-x {
border: none;
background: none;
padding: 0 2px;
font-size: 12px;
line-height: 1;
color: var(--text3);
}
.chip-x:hover:not(:disabled) {
color: var(--error);
}
.input-row {
display: flex;
gap: 8px;
}
.input-row textarea {
flex: 1;
min-width: 0;
padding: 9px 12px;
resize: none;
overflow-y: auto;
/* MAX_PROMPT_ROWS rows, then the box scrolls instead of growing. */
max-height: calc(10 * 1.55em + 18px);
}
.send {
width: 38px;
padding: 0;
font-size: 16px;
border-radius: 9px;
flex: none;
}
</style>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
// The one banner surface: claw down, auth required, stream drops. An
// inline band, dismissible when the caller allows it.
defineProps<{
variant: 'error' | 'warn' | 'info'
dismissible?: boolean
}>()
defineEmits<{ dismiss: [] }>()
</script>
<template>
<div class="banner" :class="variant" role="status">
<span class="banner-mark" aria-hidden="true">{{
variant === 'error' ? '✗' : variant === 'warn' ? '△' : '·'
}}</span>
<span class="banner-text"><slot /></span>
<slot name="actions" />
<button v-if="dismissible" class="banner-dismiss" title="dismiss" @click="$emit('dismiss')">
×
</button>
</div>
</template>
<style scoped>
.banner-mark {
color: inherit;
flex: none;
}
.banner.error .banner-mark {
color: var(--error);
}
.banner.warn .banner-mark {
color: var(--warn);
}
.banner.info .banner-mark {
color: var(--info);
}
.banner-dismiss {
padding: 0 8px;
border: none;
background: none;
color: var(--text3);
font-size: 14px;
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
// The one page header: title, an optional subtitle line, and the page's
// actions (Save/Discard-style buttons) on the right.
defineProps<{
title: string
subtitle?: string
}>()
</script>
<template>
<header class="page-head">
<div class="text">
<h1>{{ title }}</h1>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<div class="actions">
<slot name="actions" />
</div>
</header>
</template>
<style scoped>
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 24px 14px;
}
h1 {
margin: 0;
font-size: 19px;
font-weight: 700;
}
.text p {
margin: 2px 0 0;
color: var(--text3);
}
.actions {
display: flex;
align-items: center;
gap: 8px;
flex: none;
}
</style>
+316
View File
@@ -0,0 +1,316 @@
<script setup lang="ts">
// The session inventory: the dispatch keys a human converses with,
// interactive first. Clicking selects the session the chat mirrors.
// Displayed names drop the `interactive:` prefix — the full key stays
// in the tooltip. Creating is intent only: the name flows up, the
// shell answers, and the form closes when the new key lands in the
// inventory.
import { SESSION_NAME_PATTERN } from '~/composables/useClaw'
import type { ClawSession } from '~/composables/useClaw'
const props = defineProps<{
sessions: ClawSession[]
activeKey: string
loading: boolean
identity?: { name: string | null; tagline: string | null; mark: string | null; initials: string | null } | null
avatarUrl?: string | null
}>()
const emit = defineEmits<{ select: [key: string]; refresh: []; create: [name: string] }>()
interface Group {
label: string
rows: { key: string; name: string; session: ClawSession }[]
}
const groups = computed<Group[]>(() => {
const byKey = (a: ClawSession, b: ClawSession) => a.key.localeCompare(b.key)
// `phone:<device>:<topic>` keys carry the phone client's status
// reporting (battery, network, dnd), not conversation; the inventory
// hides them rather than the API narrowing.
const conversable = props.sessions.filter((session) => !session.key.startsWith('phone:'))
const interactive = conversable
.filter((session) => session.key.startsWith('interactive:'))
.sort(byKey)
const others = conversable
.filter((session) => !session.key.startsWith('interactive:'))
.sort(byKey)
const row = (session: ClawSession) => ({
key: session.key,
name: session.key.replace(/^interactive:/, ''),
session,
})
const groups: Group[] = []
if (interactive.length > 0)
groups.push({ label: 'interactive', rows: interactive.map(row) })
if (others.length > 0) groups.push({ label: 'other keys', rows: others.map(row) })
return groups
})
const shownCount = computed(() =>
groups.value.reduce((total, group) => total + group.rows.length, 0),
)
function stateTone(state: string): 'ok' | 'warn' | 'idle' {
if (state === 'leased') return 'ok'
if (state === 'reacquire') return 'warn'
return 'idle'
}
// --- new sessions -----------------------------------------------------
const creating = ref(false)
const nameDraft = ref('')
const nameInput = ref<HTMLInputElement | null>(null)
/** The name this form is waiting on; the create is in flight. */
const pendingCreate = ref<string | null>(null)
const nameValid = computed(() => SESSION_NAME_PATTERN.test(nameDraft.value))
function openCreate(): void {
creating.value = true
nameDraft.value = ''
void nextTick(() => nameInput.value?.focus())
}
function closeCreate(): void {
creating.value = false
nameDraft.value = ''
pendingCreate.value = null
}
function submitCreate(): void {
if (!nameValid.value) return
pendingCreate.value = nameDraft.value
emit('create', nameDraft.value)
}
// The form closes when the claw answers with the session in the
// inventory; a failed create answers nothing, and the form stays.
watch(
() => props.sessions,
(sessions) => {
if (
pendingCreate.value !== null &&
sessions.some((session) => session.key === `interactive:${pendingCreate.value}`)
) {
closeCreate()
}
},
)
</script>
<template>
<aside class="sidebar">
<header class="agent">
<AgentAvatar
:avatar-url="avatarUrl"
:initials="identity?.initials ?? identity?.name?.slice(0, 1)"
:size="34"
/>
<div class="agent-text">
<strong>{{ identity?.name?.trim() || 'rhema' }}</strong>
<span>{{ identity?.tagline?.trim() || 'the spoken word' }}</span>
</div>
</header>
<header class="head">
<span class="label">Sessions</span>
<span class="count">{{ shownCount }}</span>
<button class="ghost" title="new session" :disabled="creating" @click="creating ? closeCreate() : openCreate()">
+
</button>
<button class="ghost" title="refresh sessions" :disabled="loading" @click="$emit('refresh')">
↻
</button>
</header>
<form v-if="creating" class="new-session" @submit.prevent="submitCreate">
<input
ref="nameInput"
v-model="nameDraft"
type="text"
class="mono"
placeholder="session name"
autocomplete="off"
maxlength="64"
/>
<div class="new-actions">
<button type="button" class="ghost" @click="closeCreate">cancel</button>
<button type="submit" class="primary" :disabled="!nameValid">create</button>
</div>
<p v-if="nameDraft !== '' && !nameValid" class="new-note">
letters, digits, dots, dashes, underscores — up to 64.
</p>
</form>
<p v-if="shownCount === 0" class="empty">
{{ loading ? 'loading…' : 'no sessions yet; send the first message' }}
</p>
<div v-for="group in groups" :key="group.label" class="group">
<span class="label">{{ group.label }}</span>
<button
v-for="row in group.rows"
:key="row.key"
class="entry"
:class="{ active: row.key === activeKey }"
:title="row.key"
@click="$emit('select', row.key)"
>
<StatusDot :tone="stateTone(row.session.state)" />
<span class="name">{{ row.name }}</span>
<span v-if="row.session.inbox_depth > 0" class="depth" title="inbox depth">
{{ row.session.inbox_depth }}
</span>
</button>
</div>
</aside>
</template>
<style scoped>
.sidebar {
display: flex;
flex-direction: column;
min-height: 0;
width: 240px;
flex: none;
padding: 14px 10px;
border-right: 1px solid var(--border);
background: var(--surface);
overflow-y: auto;
}
.agent {
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 10px;
}
.agent-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.agent-text strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-text span {
color: var(--text3);
font-size: 11.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.head {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px 10px;
}
.count {
flex: 1;
color: var(--text3);
font-size: 12px;
}
.head .ghost {
padding: 0 6px;
}
.new-session {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 8px 10px;
}
.new-session input {
width: 100%;
font-size: 12.5px;
}
.new-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.new-actions button {
padding: 3px 10px;
font-size: 12px;
}
.new-note {
margin: 0;
color: var(--text3);
font-size: 11.5px;
}
.empty {
margin: 4px 8px;
color: var(--text3);
}
.group {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 14px;
}
.group .label {
padding: 0 8px 4px;
}
.entry {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
background: none;
border: none;
border-radius: 7px;
padding: 7px 10px;
color: var(--text2);
}
.entry:hover:not(:disabled) {
background: var(--elevated);
color: var(--text);
}
.entry.active {
background: color-mix(in srgb, var(--accent) 17%, transparent);
color: var(--accent-text);
font-weight: 600;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.depth {
flex: none;
min-width: 18px;
text-align: center;
font-size: 11px;
font-family: var(--mono);
color: var(--info);
border: 1px solid var(--border-strong);
border-radius: 999px;
padding: 0 5px;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
// One titled card of setting rows or static facts — the repeating
// surface of every settings page. The header may carry a meta note on
// the right ("4 unset — inherit the primary model"-style).
defineProps<{
title: string
meta?: string
}>()
</script>
<template>
<section class="card">
<header class="card-head">
<span class="title">{{ title }}</span>
<span v-if="meta || $slots.meta" class="meta"><slot name="meta">{{ meta }}</slot></span>
</header>
<div class="card-body">
<slot />
</div>
</section>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
// One row inside a SettingCard: name and explanation on the left, the
// control (or value) on the right. Controls align center; tall custom
// content can opt out with `top`.
defineProps<{
title: string
description?: string
/** Align the control to the row's top instead of its center. */
top?: boolean
}>()
</script>
<template>
<div class="row" :class="{ top }">
<div class="text">
<span class="name">{{ title }}</span>
<span v-if="description" class="description">{{ description }}</span>
</div>
<div class="control">
<slot />
</div>
</div>
</template>
<style scoped>
.row {
display: flex;
align-items: center;
gap: 24px;
padding: 13px 16px;
}
.row.top {
align-items: flex-start;
}
.row.top .control {
margin-top: 2px;
}
.text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.name {
color: var(--text);
font-weight: 600;
}
.description {
color: var(--text3);
font-size: 12.5px;
overflow-wrap: anywhere;
}
.control {
flex: none;
max-width: 55%;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.control :deep(select),
.control :deep(input) {
width: 100%;
min-width: 220px;
}
@media (max-width: 760px) {
.row {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.control,
.control :deep(select),
.control :deep(input) {
max-width: none;
width: 100%;
min-width: 0;
}
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
// The settings nav: searchable sections of pages, the design's left
// column. The search is a client-side filter over these entries — it
// hides nothing the claw cannot serve.
export interface SettingsEntry {
id: string
label: string
}
export interface SettingsSection {
label: string
entries: SettingsEntry[]
}
const props = defineProps<{
sections: SettingsSection[]
active: string
/** Health echo for the footer, so the nav answers "am I connected". */
clawUp: boolean | null
}>()
defineEmits<{ select: [id: string] }>()
const query = ref('')
const filtered = computed<SettingsSection[]>(() => {
const needle = query.value.trim().toLowerCase()
if (!needle) return props.sections
return props.sections
.map((section) => ({
label: section.label,
entries: section.entries.filter((entry) =>
entry.label.toLowerCase().includes(needle),
),
}))
.filter((section) => section.entries.length > 0)
})
</script>
<template>
<aside class="nav">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" />
<path d="m20 20-3.8-3.8" />
</svg>
<input v-model="query" type="text" placeholder="Search settings" />
</div>
<p v-if="filtered.length === 0" class="empty">no settings match</p>
<div v-for="section in filtered" :key="section.label" class="section">
<span class="label">{{ section.label }}</span>
<button
v-for="entry in section.entries"
:key="entry.id"
class="entry"
:class="{ active: entry.id === active }"
@click="$emit('select', entry.id)"
>
{{ entry.label }}
</button>
</div>
<div class="spacer" />
<footer class="foot">
<StatusDot :tone="clawUp === null ? 'idle' : clawUp ? 'ok' : 'error'" />
<span>{{ clawUp === null ? 'checking claw' : clawUp ? 'claw connected' : 'claw unreachable' }}</span>
</footer>
</aside>
</template>
<style scoped>
.nav {
display: flex;
flex-direction: column;
gap: 4px;
width: 232px;
flex: none;
min-height: 0;
padding: 14px 10px 10px;
border-right: 1px solid var(--border);
background: var(--surface);
overflow-y: auto;
}
.search {
position: relative;
margin-bottom: 12px;
}
.search svg {
position: absolute;
left: 9px;
top: 50%;
width: 14px;
height: 14px;
transform: translateY(-50%);
color: var(--text3);
pointer-events: none;
}
.search input {
width: 100%;
padding: 6px 10px 6px 28px;
font-size: 13px;
}
.empty {
margin: 4px 6px;
color: var(--text3);
}
.section {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 14px;
}
.section .label {
padding: 0 10px 4px;
}
.entry {
text-align: left;
background: none;
border: none;
border-radius: 7px;
padding: 6px 10px;
color: var(--text2);
}
.entry:hover:not(:disabled) {
background: var(--elevated);
color: var(--text);
}
.entry.active {
background: color-mix(in srgb, var(--accent) 17%, transparent);
color: var(--accent-text);
font-weight: 600;
}
.spacer {
flex: 1;
}
.foot {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
border-top: 1px solid var(--border);
color: var(--text3);
font-size: 12px;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
// One small round state dot with an honest tone: ok, warn, error, or
// idle gray. The only place status color lives at a glance.
defineProps<{
tone: 'ok' | 'warn' | 'error' | 'idle'
}>()
</script>
<template>
<span class="dot" :class="tone" aria-hidden="true" />
</template>
<style scoped>
.dot {
display: inline-block;
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text3);
}
.dot.ok {
background: var(--ok);
}
.dot.warn {
background: var(--warn);
}
.dot.error {
background: var(--error);
}
</style>
@@ -0,0 +1,110 @@
<script setup lang="ts">
// The agent handing a file over: a head row naming it, the image (when
// there is one) under it, capped so a screenshot cannot take the
// stream; code and file download on click.
import type { AttachmentCard } from '~/composables/useTranscript'
defineProps<{ attachment: AttachmentCard }>()
const emit = defineEmits<{
download: [id: string, name: string]
}>()
const MEDIA_GLYPHS: Record<AttachmentCard['media'], string> = {
image: '▣',
code: '⌗',
file: '▤',
}
/** B / KB / MB, one decimal past 1 KB — a card label, not precision. */
function formatBytes(size: number): string {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
</script>
<template>
<div class="attachment-card">
<div class="attachment-head">
<span
class="attachment-glyph"
aria-hidden="true"
:title="`${attachment.media} attachment`"
>{{ MEDIA_GLYPHS[attachment.media] }}</span>
<span class="attachment-name mono">{{ attachment.name }}</span>
<span class="attachment-size mono">{{ formatBytes(attachment.size) }}</span>
<button
v-if="attachment.media !== 'image'"
class="ghost"
:title="`download ${attachment.name}`"
@click="emit('download', attachment.id, attachment.name)"
>
↓ download
</button>
</div>
<img
v-if="attachment.media === 'image' && attachment.url"
:src="attachment.url"
:alt="attachment.caption ?? attachment.name"
class="attachment-image"
/>
<p v-if="attachment.caption" class="attachment-caption">
{{ attachment.caption }}
</p>
</div>
</template>
<style scoped>
.attachment-card {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 480px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.attachment-head {
display: flex;
align-items: center;
gap: 8px;
}
.attachment-glyph {
flex: none;
color: var(--text3);
}
.attachment-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.attachment-size {
flex: none;
color: var(--text3);
font-size: 11px;
}
.attachment-image {
max-width: 100%;
max-height: 240px;
align-self: flex-start;
border: 1px solid var(--border);
border-radius: 8px;
}
.attachment-caption {
margin: 0;
color: var(--text3);
font-size: 12.5px;
overflow-wrap: anywhere;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
// The agent asking its human something: a secret to store, permission
// to act. Answering is intent only — the shell POSTs, and the card
// flips on the local echo or the stream's consent_resolved.
import { ref } from 'vue'
import type { ConsentCard } from '~/composables/useTranscript'
import type { ConsentAnswer } from '~/composables/useClaw'
const props = defineProps<{ consent: ConsentCard }>()
const emit = defineEmits<{
answer: [id: string, answer: ConsentAnswer]
deny: [id: string]
}>()
const draft = ref('')
function tone(consent: ConsentCard): 'pending' | 'ok' | 'denied' {
if (consent.granted === null) return 'pending'
return consent.granted ? 'ok' : 'denied'
}
function answerSecret(): void {
const secret = draft.value.trim()
if (secret === '') return
emit('answer', props.consent.id, { secret })
draft.value = ''
}
</script>
<template>
<div class="consent-card" :class="tone(consent)">
<div class="consent-head">
<span class="badge">{{ consent.kind }}</span>
<strong class="consent-summary">{{ consent.summary }}</strong>
<span v-if="consent.granted !== null" class="consent-state mono">
{{ consent.granted ? 'granted' : 'denied' }}
</span>
</div>
<p v-if="consent.detail" class="consent-detail">{{ consent.detail }}</p>
<div v-if="consent.granted === null" class="consent-actions">
<template v-if="consent.kind === 'secret'">
<input
v-model="draft"
type="password"
placeholder="the secret, never shown again"
autocomplete="off"
@keydown.enter="answerSecret"
/>
<button
class="primary"
:disabled="draft.trim() === ''"
@click="answerSecret"
>
submit
</button>
</template>
<template v-else>
<button class="primary" @click="emit('answer', consent.id, { grant: true })">
Approve
</button>
<button @click="emit('answer', consent.id, { grant: false })">
Deny
</button>
</template>
<button class="ghost" title="cancel without answering" @click="emit('deny', consent.id)">
deny
</button>
</div>
</div>
</template>
<style scoped>
.consent-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--warn);
border-radius: 6px;
}
.consent-card.ok {
border-left-color: var(--ok);
}
.consent-card.denied {
border-left-color: var(--error);
}
.consent-head {
display: flex;
align-items: center;
gap: 8px;
}
.consent-summary {
flex: 1;
min-width: 0;
font-size: 13px;
}
.denied .consent-summary {
color: var(--text3);
}
.consent-state {
flex: none;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ok);
}
.denied .consent-state {
color: var(--error);
}
.consent-detail {
margin: 0;
color: var(--text2);
font-size: 12.5px;
overflow-wrap: anywhere;
}
.consent-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.consent-actions input {
flex: 1;
min-width: 160px;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
// A turn's stream-ordered middle: tool rows, the agent's asks, its
// handovers — each at the position the stream delivered it, so an ask
// sits at the tool call that raised it rather than at the turn's top.
import type { TurnEntry } from '~/composables/useTranscript'
import type { ConsentAnswer } from '~/composables/useClaw'
defineProps<{ entries: TurnEntry[] }>()
const emit = defineEmits<{
consentAnswer: [id: string, answer: ConsentAnswer]
consentDeny: [id: string]
downloadAttachment: [id: string, name: string]
}>()
</script>
<template>
<div v-if="entries.length > 0" class="entries">
<template v-for="(entry, index) in entries" :key="index">
<TurnToolRow v-if="entry.kind === 'tool'" :row="entry.row" />
<TurnConsentCard
v-else-if="entry.kind === 'consent'"
:consent="entry.consent"
@answer="(id, answer) => emit('consentAnswer', id, answer)"
@deny="(id) => emit('consentDeny', id)"
/>
<TurnAttachmentCard
v-else
:attachment="entry.attachment"
@download="(id, name) => emit('downloadAttachment', id, name)"
/>
</template>
</div>
</template>
<style scoped>
.entries {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
// One tool call in a turn: running, completed, or failed — shell lines
// for bash (the TUI's tool_row), glyph rows for everything else.
import type { ToolRow } from '~/composables/useTranscript'
defineProps<{ row: ToolRow }>()
/** Command rows render as shell lines, not glyph rows; run_command is
* the bash tool's other name. */
function isShellTool(name: string): boolean {
return name === 'bash' || name === 'run_command'
}
</script>
<template>
<div class="tool-row" :class="{ error: row.isError === true }">
<template v-if="isShellTool(row.name)">
<span class="shell-mark" aria-hidden="true">$ </span>
<span class="shell-command mono">{{ row.command ?? '…' }}</span>
<span
v-if="typeof row.code === 'number'"
class="shell-code mono"
:class="row.code === 0 ? 'ok' : 'failed'"
>[{{ row.code }}]</span>
<span v-else-if="row.isError === true" class="shell-code failed">✗</span>
</template>
<template v-else>
<span class="glyph" aria-hidden="true">{{
row.isError === null ? '…' : row.isError ? '✗' : '✓'
}}</span>
<span class="name mono">{{ row.name }}</span>
<span v-if="row.summary" class="summary mono">{{ row.summary }}</span>
</template>
</div>
</template>
<style scoped>
.tool-row {
display: flex;
gap: 8px;
align-items: baseline;
padding: 5px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--ok);
border-radius: 6px;
}
.tool-row.error {
border-left-color: var(--error);
}
.glyph {
flex: none;
color: var(--ok);
}
.tool-row.error .glyph {
color: var(--error);
}
.name {
color: var(--text);
}
.summary {
color: var(--text3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A bash row reads as the shell line it ran: dim $ prefix, the
command, and the exit code pushed to the row's right end. */
.shell-mark {
flex: none;
color: var(--text3);
white-space: pre;
}
.shell-command {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.shell-code {
flex: none;
font-size: 11px;
}
.shell-code.ok {
color: var(--ok);
}
.shell-code.failed {
color: var(--error);
}
</style>
@@ -0,0 +1,154 @@
<script setup lang="ts">
// The About page: what this client and the daemon behind it are, and
// which public routes the client speaks. Static facts — the claw's own
// version is not on the wire, so none is claimed.
defineProps<{
/** null while /healthz has not answered yet. */
clawUp: boolean | null
}>()
// The routes this client actually calls; the full surface lives in
// crates/rhema-claw/src/http.rs.
const routes = [
{ method: 'GET', path: '/healthz', note: 'liveness, open without a token' },
{ method: 'GET', path: '/v1/sessions', note: 'the dispatch-key inventory' },
{ method: 'POST', path: '/v1/sessions', note: 'create an interactive session from a name' },
{ method: 'POST', path: '/v1/sessions/{key}/messages', note: 'message ingest, 202 + stream' },
{ method: 'GET', path: '/v1/sessions/{key}/events', note: 'the turn-event stream (SSE)' },
{ method: 'POST', path: '/v1/consents/{id}', note: 'answer a consent (a secret or a grant)' },
{ method: 'DELETE', path: '/v1/consents/{id}', note: 'deny or cancel a consent' },
{ method: 'GET', path: '/v1/attachments/{id}', note: "an attachment's bytes" },
{ method: 'GET', path: '/v1/providers', note: 'constructible backends + active pair' },
{ method: 'PUT', path: '/v1/providers/active', note: 'switch the backend, persisted' },
{ method: 'PUT', path: '/v1/providers/{kind}/credentials', note: 'add a backend or replace its login' },
{ method: 'DELETE', path: '/v1/providers/{kind}', note: 'drop a backend from the roster' },
{ method: 'GET', path: '/v1/providers/{kind}/models', note: "a kind's live model listing" },
]
</script>
<template>
<div class="page">
<PageHeader title="About" subtitle="Identity, the engine behind it, and the surface this client speaks" />
<div class="scroll">
<div class="identity">
<div class="avatar" aria-hidden="true">R</div>
<div>
<h2>Rhema</h2>
<p>ῥῆμα, "the spoken word": words that do things.</p>
</div>
</div>
<AppCallout variant="info" title="This is the claw's web client">
A mirror over one running <code>rhema-claw</code> daemon: the
conversation lives in the claw's sessions, the model choice in its
settings store. A reload starts empty by design.
</AppCallout>
<SettingCard title="Stack" meta="read-only">
<SettingRow title="Daemon" description="The resident agent serving this UI.">
<span class="mono">rhema-claw</span>
</SettingRow>
<SettingRow title="Engine" description="The step engine every turn runs through.">
<span class="mono">rhema-core</span>
</SettingRow>
<SettingRow title="Web client" description="This dashboard: Nuxt 4, one static bundle.">
<span class="mono">rhema-claw-web 0.1.0</span>
</SettingRow>
<SettingRow title="Claw status" description="What /healthz last answered.">
<AppBadge :variant="clawUp === null ? undefined : clawUp ? 'ok' : 'error'">
{{ clawUp === null ? 'checking' : clawUp ? 'up' : 'down' }}
</AppBadge>
</SettingRow>
</SettingCard>
<SettingCard title="API surface" meta="the routes this client calls">
<div v-for="route in routes" :key="`${route.method} ${route.path}`" class="route">
<span class="method mono">{{ route.method }}</span>
<span class="path mono">{{ route.path }}</span>
<span class="note">{{ route.note }}</span>
</div>
</SettingCard>
</div>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
flex: 1;
}
.scroll {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 860px;
}
.identity {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 2px;
}
.avatar {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: var(--accent);
color: var(--on-accent);
font-size: 26px;
font-weight: 700;
flex: none;
}
.identity h2 {
margin: 0;
font-size: 22px;
}
.identity p {
margin: 2px 0 0;
color: var(--text3);
}
.route {
display: flex;
align-items: baseline;
gap: 12px;
padding: 9px 16px;
}
.method {
flex: none;
width: 44px;
color: var(--accent-text);
font-size: 12px;
}
.path {
color: var(--text);
font-size: 12.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note {
flex: 1;
text-align: right;
color: var(--text3);
font-size: 12px;
min-width: 0;
}
</style>
@@ -0,0 +1,250 @@
<script setup lang="ts">
// The Agent · Accounts page: logins the agent itself holds on external
// services. Presentational like its siblings — data flows in as props,
// intent flows up as events; the secret field is write-only by the
// wire's shape, and the roster can only ever render what `GET /v1/
// accounts` serves, which is the redacted record.
import type { ClawAccount, NewAccount } from '~/composables/useClaw'
const props = defineProps<{
accounts: ClawAccount[] | null
loading: boolean
/** A save or forget is in flight. */
mutating: boolean
}>()
const emit = defineEmits<{
add: [account: NewAccount]
remove: [account: ClawAccount]
}>()
const adding = ref(false)
/// The form's own shape: every field a string (empty = omit on the
/// wire), so the template never fights `NewAccount`'s optional fields.
const draft = ref({
label: '',
service: '',
url: '',
username: '',
secret: '',
secret_kind: 'token' as 'token' | 'password',
watch: false,
})
const formValid = computed(
() =>
draft.value.label.trim() !== '' &&
draft.value.service.trim() !== '' &&
draft.value.secret.trim() !== '',
)
function submit(): void {
if (!formValid.value || props.mutating) return
// Empty optionals are omitted, never sent as "" strings.
const payload: NewAccount = {
label: draft.value.label.trim(),
service: draft.value.service.trim().toLowerCase(),
secret: draft.value.secret,
}
if (draft.value.url.trim() !== '') payload.url = draft.value.url.trim()
if (draft.value.username.trim() !== '') payload.username = draft.value.username.trim()
if (draft.value.secret_kind === 'password') payload.secret_kind = 'password'
if (draft.value.watch) payload.watch = true
emit('add', payload)
draft.value = {
label: '',
service: '',
url: '',
username: '',
secret: '',
secret_kind: 'token',
watch: false,
}
adding.value = false
}
// A forget cascades the account's derived watches, so it asks twice.
const confirming = ref<string | null>(null)
function forget(account: ClawAccount): void {
if (props.mutating) return
if (confirming.value !== account.id) {
confirming.value = account.id
return
}
confirming.value = null
emit('remove', account)
}
function meta(account: ClawAccount): string {
const parts = [account.service]
if (account.username) parts.push(account.username)
if (account.url) parts.push(account.url)
return parts.join(' · ')
}
</script>
<template>
<div class="page">
<PageHeader title="Accounts" subtitle="Who the agent is on the outside">
<template #actions>
<button class="primary" :disabled="mutating" @click="adding = !adding">
{{ adding ? 'Close' : 'Add account…' }}
</button>
</template>
</PageHeader>
<div class="scroll">
<SettingCard v-if="adding" title="New account" meta="the secret is stored, never shown again">
<SettingRow title="Label" description="A name for the roster, e.g. “acme releases”.">
<input
v-model="draft.label"
type="text"
maxlength="128"
placeholder="acme releases"
autocomplete="off"
:disabled="mutating"
/>
</SettingRow>
<SettingRow
title="Service"
description="Lowercase: github, gitlab, or gitea — or any name for a service the agent only stores."
>
<input
v-model="draft.service"
type="text"
placeholder="github"
autocomplete="off"
:disabled="mutating"
/>
</SettingRow>
<SettingRow title="URL" description="Base URL, for self-hosted instances.">
<input
v-model="draft.url"
type="text"
placeholder="https://git.example.net"
autocomplete="off"
:disabled="mutating"
/>
</SettingRow>
<SettingRow title="Username" description="The login name on the service.">
<input
v-model="draft.username"
type="text"
placeholder="optional"
autocomplete="off"
:disabled="mutating"
/>
</SettingRow>
<SettingRow title="Secret" description="An API token — or a password, which the agent stores but can never use.">
<input
v-model="draft.secret"
type="password"
placeholder="paste the credential"
autocomplete="off"
:disabled="mutating"
/>
</SettingRow>
<SettingRow title="Kind" description="What the secret authenticates as.">
<select v-model="draft.secret_kind" :disabled="mutating">
<option value="token">token</option>
<option value="password">password</option>
</select>
</SettingRow>
<SettingRow
title="Follow notifications"
description="For a forge service: watch everything addressed to this account, triaged into agent work."
>
<input v-model="draft.watch" type="checkbox" :disabled="mutating" />
</SettingRow>
<div class="actions">
<button :disabled="mutating" @click="adding = false">Cancel</button>
<button class="primary" :disabled="!formValid || mutating" @click="submit">
{{ mutating ? 'Saving…' : 'Save account' }}
</button>
</div>
</SettingCard>
<SettingCard
title="Agent accounts"
:meta="accounts?.length ? `${accounts.length}` : 'none yet'"
>
<p v-if="loading" class="dim">loading…</p>
<p v-else-if="!accounts?.length" class="dim">
No agent accounts yet. An account is a login the agent holds on
an external service — a GitHub identity it answers issues as,
an email it sends from — distinct from the model providers
(what it thinks with) and the forge watches (what it looks at).
For a forge identity, “follow notifications” turns the account
into watches automatically: review requests, assignments, and
feedback become agent work, mentions surface to you.
</p>
<SettingRow
v-for="account in accounts"
:key="account.id"
:title="account.label"
:description="meta(account)"
>
<div class="roster-control">
<span class="kind">{{ account.secret_kind }}</span>
<button
class="ghost"
:disabled="mutating"
@click="forget(account)"
>
{{
confirming === account.id
? 'Forget — also drops its watches?'
: 'Forget…'
}}
</button>
</div>
</SettingRow>
</SettingCard>
</div>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
flex: 1;
}
.scroll {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 860px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 8px 16px 14px;
}
.dim {
margin: 0;
padding: 14px 16px;
color: var(--text2);
}
.roster-control {
display: flex;
align-items: center;
gap: 8px;
}
.kind {
font-size: 12px;
color: var(--text3);
}
</style>
@@ -0,0 +1,298 @@
<script setup lang="ts">
// The Agent · General page: the mock's identity editor. The text
// fields save as a whole (empty clears, absent is untouched claw-side);
// the images apply the moment a file is chosen — uploading is not the
// kind of edit that needs a staging area. Presentational: data flows
// in as props, intent flows up as events.
import type { AgentIdentity, AgentImageKind, IdentityTexts } from '~/composables/useClaw'
const props = defineProps<{
identity: AgentIdentity | null
avatarUrl: string | null
logoUrl: string | null
/** An image upload or clear is in flight. */
mutating: boolean
saving: boolean
}>()
const emit = defineEmits<{
save: [texts: IdentityTexts]
uploadImage: [kind: AgentImageKind, file: File]
clearImage: [kind: AgentImageKind]
}>()
// The draft: '' means "clear this field", matching the wire.
const draft = ref<IdentityTexts>({
name: '',
mark: '',
initials: '',
tagline: '',
signoff: '',
})
const dirty = computed(() => {
const identity = props.identity
if (!identity) return false
return (
draft.value.name !== (identity.name ?? '') ||
draft.value.mark !== (identity.mark ?? '') ||
draft.value.initials !== (identity.initials ?? '') ||
draft.value.tagline !== (identity.tagline ?? '') ||
draft.value.signoff !== (identity.signoff ?? '')
)
})
function discard(): void {
const identity = props.identity
if (!identity) return
draft.value = {
name: identity.name ?? '',
mark: identity.mark ?? '',
initials: identity.initials ?? '',
tagline: identity.tagline ?? '',
signoff: identity.signoff ?? '',
}
}
// The draft starts empty, which reads as "dirty" against a saved
// identity — the first arrival syncs unconditionally, later ones only
// when the operator has not typed.
const initialized = ref(false)
watch(
() => props.identity,
(identity) => {
if (!identity || (initialized.value && dirty.value)) return
initialized.value = true
draft.value = {
name: identity.name ?? '',
mark: identity.mark ?? '',
initials: identity.initials ?? '',
tagline: identity.tagline ?? '',
signoff: identity.signoff ?? '',
}
},
{ immediate: true },
)
function save(): void {
if (!dirty.value || props.saving) return
emit('save', { ...draft.value })
}
// --- the images -------------------------------------------------------
const avatarInput = ref<HTMLInputElement | null>(null)
const logoInput = ref<HTMLInputElement | null>(null)
function pickImage(kind: AgentImageKind): void {
;(kind === 'avatar' ? avatarInput : logoInput).value?.click()
}
function onImageChosen(kind: AgentImageKind, event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) emit('uploadImage', kind, file)
input.value = ''
}
const CAPS: Record<keyof IdentityTexts, number> = {
name: 64,
mark: 8,
initials: 4,
tagline: 120,
signoff: 120,
}
</script>
<template>
<div class="page">
<PageHeader
title="General"
subtitle="The agent's identity — how every surface names and shows it"
>
<template #actions>
<button :disabled="!dirty || saving" @click="discard">Discard</button>
<button class="primary" :disabled="!dirty || saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</template>
</PageHeader>
<div class="scroll">
<div class="identity">
<AgentAvatar
:avatar-url="avatarUrl"
:mark="draft.mark"
:initials="draft.initials || draft.name.slice(0, 1)"
:size="56"
variant="initials"
/>
<div class="identity-text">
<h2>{{ draft.name || 'rhema' }}</h2>
<p>{{ draft.tagline || 'the spoken word' }}</p>
</div>
</div>
<AppCallout variant="info" title="This is the agent's identity, not the engine's">
Everything in the interface uses the name, mark, and images here —
the rail, the chat, the window title, the notifications. The
engine's own name appears only where it is a component.
</AppCallout>
<SettingCard v-if="identity" title="Agent identity">
<SettingRow
title="Display name"
description="Shown as the author of messages, in the window title, and in notifications."
>
<input
v-model="draft.name"
type="text"
:maxlength="CAPS.name"
placeholder="rhema"
autocomplete="off"
:disabled="saving"
/>
</SettingRow>
<SettingRow
title="Mark"
description="The glyph or emoji in the rail and the empty states. Defaults to ῥ."
>
<input
v-model="draft.mark"
type="text"
:maxlength="CAPS.mark"
placeholder="ῥ"
autocomplete="off"
:disabled="saving"
/>
</SettingRow>
<SettingRow
title="Initials"
description="Used when there is no mark and no avatar."
>
<input
v-model="draft.initials"
type="text"
:maxlength="CAPS.initials"
:placeholder="draft.name.slice(0, 1) || 'R'"
autocomplete="off"
:disabled="saving"
/>
</SettingRow>
<SettingRow title="Tagline" description="One line under the name on the agent pane.">
<input
v-model="draft.tagline"
type="text"
:maxlength="CAPS.tagline"
placeholder="the spoken word"
autocomplete="off"
:disabled="saving"
/>
</SettingRow>
<SettingRow
title="Sign-off"
description="Appended to messages the agent sends outside the interface."
>
<input
v-model="draft.signoff"
type="text"
:maxlength="CAPS.signoff"
placeholder="optional"
autocomplete="off"
:disabled="saving"
/>
</SettingRow>
</SettingCard>
<SettingCard
v-if="identity"
title="Avatar & logo"
meta="png or jpeg, up to 2 MiB — uploads apply immediately"
>
<SettingRow
title="Avatar"
description="The square face in the chat and the rail."
>
<div class="image-control">
<AgentAvatar :avatar-url="avatarUrl" :mark="identity.mark" :initials="identity.initials ?? identity.name?.slice(0, 1)" :size="40" variant="initials" />
<button :disabled="mutating" @click="pickImage('avatar')">Upload…</button>
<button v-if="identity.avatar" class="ghost" :disabled="mutating" @click="emit('clearImage', 'avatar')">
Clear
</button>
<input
ref="avatarInput"
type="file"
accept="image/png,image/jpeg,image/webp"
@change="onImageChosen('avatar', $event)"
/>
</div>
</SettingRow>
<SettingRow
title="Logo"
description="The mark the empty states and headers render."
>
<div class="image-control">
<AgentAvatar :avatar-url="logoUrl" :mark="identity.mark" :size="40" variant="mark" />
<button :disabled="mutating" @click="pickImage('logo')">Upload…</button>
<button v-if="identity.logo" class="ghost" :disabled="mutating" @click="emit('clearImage', 'logo')">
Clear
</button>
<input
ref="logoInput"
type="file"
accept="image/png,image/jpeg,image/webp"
@change="onImageChosen('logo', $event)"
/>
</div>
</SettingRow>
</SettingCard>
</div>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
flex: 1;
}
.scroll {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 860px;
}
.identity {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 2px;
}
.identity-text h2 {
margin: 0;
font-size: 22px;
}
.identity-text p {
margin: 2px 0 0;
color: var(--text3);
}
.image-control {
display: flex;
align-items: center;
gap: 8px;
}
.image-control input[type='file'] {
display: none;
}
</style>
@@ -0,0 +1,491 @@
<script setup lang="ts">
// The Model providers page: the primary backend the claw answers turns
// with, plus the provider roster itself. Pair edits stage into a local
// draft; Save PUTs it (the claw applies the pair on the next turn and
// persists it), Discard reverts to what the claw reports. Logins apply
// immediately: adding an instance constructs its backend claw-side and
// persists its credentials, removing one drops it (env-backed kinds
// re-discover on restart — the source badge says which). The live
// model listing comes from the provider itself; a backend without one
// still switches — its default model applies.
// Presentational: data flows in as props, intent flows up as events.
import type { ModelCard, ProviderCredentials, ProvidersState } from '~/composables/useClaw'
const props = defineProps<{
state: ProvidersState | null
models: ModelCard[]
modelsLoading: boolean
modelsError: string | null
/** The instance whose live listing the Model row shows. */
selectedProvider: string | null
saving: boolean
/** A login or a removal is in flight. */
mutating: boolean
}>()
const emit = defineEmits<{
/** Load (or reload) the selected instance's live listing. */
inspect: [id: string]
save: [provider: string, model?: string]
refresh: []
/** Add a backend instance (or replace an id's credentials). */
add: [id: string, credentials: ProviderCredentials]
/** Remove a backend instance from the claw's roster. */
remove: [id: string]
}>()
// The draft pair. An empty model string means "the provider's default" —
// the PUT omits the model and the claw falls back.
const draftProvider = ref<string | null>(null)
const draftModel = ref('')
const defaultModel = computed(() => {
const id = draftProvider.value
return props.state?.providers.find((p) => p.id === id)?.default_model ?? ''
})
const effectiveModel = computed(() => draftModel.value || defaultModel.value)
const dirty = computed(() => {
const active = props.state?.active
if (!active || draftProvider.value === null) return false
return (
draftProvider.value !== active.provider || effectiveModel.value !== active.model
)
})
/** Re-ground the draft on the claw's state; skipped while dirty. */
function syncDraft(): void {
const active = props.state?.active
if (!active) {
draftProvider.value = null
draftModel.value = ''
return
}
draftProvider.value = active.provider
draftModel.value = active.model === defaultModel.value ? '' : active.model
}
watch(
() => props.state,
(state) => {
// A removal can take the staged instance with it: the draft
// re-grounds even when dirty, because staging a gone provider
// saves nothing.
const stagedGone =
draftProvider.value !== null &&
!state?.providers.some((p) => p.id === draftProvider.value)
if (!dirty.value || stagedGone) syncDraft()
},
{ immediate: true },
)
function pickProvider(id: string): void {
draftProvider.value = id
draftModel.value = ''
emit('inspect', id)
}
function discard(): void {
syncDraft()
}
function save(): void {
if (!dirty.value || draftProvider.value === null) return
emit('save', draftProvider.value, draftModel.value || undefined)
}
// --- the login form ---------------------------------------------------
const KIND_LABELS: Record<string, string> = {
anthropic: 'Anthropic',
copilot: 'GitHub Copilot',
deepseek: 'DeepSeek',
google: 'Google Gemini',
'llama-cpp': 'llama.cpp server',
openai: 'OpenAI',
openrouter: 'OpenRouter',
zai: 'Z.ai (GLM Coding Plan)',
}
function kindLabel(kind: string): string {
return KIND_LABELS[kind] ?? kind
}
/** The key field's voice: copilot logs in with a GitHub token. */
const keyLabel = computed(() =>
addKind.value === 'copilot' ? 'GitHub token' : 'API key',
)
/** A field the daemon does not demand reads as optional, not hidden. */
function label(name: string, base: string): string {
return required(name) ? base : `${base} (optional)`
}
const loginOpen = ref(false)
const addKind = ref<string>('')
const addName = ref('')
const addApiKey = ref('')
const addUrl = ref('')
/** Set when a login is posted; the state swap that answers it closes
* the form (a failure leaves the state, and the form, as they were). */
const pendingAdd = ref<string | null>(null)
const addable = computed(() => props.state?.addable ?? [])
const addFields = computed(() => {
const entry = addable.value.find((entry) => entry.kind === addKind.value)
return entry?.fields ?? []
})
/** Whether the selected kind's form declares this field at all. */
function declared(name: string): boolean {
return addFields.value.some((field) => field.name === name)
}
/** Whether the daemon refuses the login without this field. */
function required(name: string): boolean {
return addFields.value.find((field) => field.name === name)?.required ?? false
}
function fieldValue(name: string): string {
return name === 'url' ? addUrl.value : name === 'api_key' ? addApiKey.value : ''
}
/** The id the login lands under: the name, or the kind unnamed. */
const resultingId = computed(() => addName.value.trim() || addKind.value)
const nameValid = computed(() => {
const name = addName.value.trim()
return name === '' || (name.length <= 64 && !/\s/.test(name) && !name.includes('/'))
})
const addIdPresent = computed(() =>
props.state?.providers.some((p) => p.id === resultingId.value) ?? false,
)
const addReady = computed(() => {
if (!addKind.value || !nameValid.value) return false
return addFields.value
.filter((field) => field.required)
.every((field) => fieldValue(field.name).trim() !== '')
})
function submitAdd(): void {
if (!addReady.value || props.mutating) return
// Only declared fields go out, and only filled ones: the claw treats
// an empty string as the value it was handed, not as "absent".
const credentials: ProviderCredentials = { kind: addKind.value }
if (addName.value.trim() !== '') credentials.name = addName.value.trim()
if (declared('api_key') && addApiKey.value.trim() !== '') {
credentials.api_key = addApiKey.value.trim()
}
if (declared('url') && addUrl.value.trim() !== '') {
credentials.url = addUrl.value.trim()
}
pendingAdd.value = resultingId.value
emit('add', resultingId.value, credentials)
}
function sourceTitle(source: string): string {
switch (source) {
case 'env':
return 'credential from the environment; a removal lasts until restart'
case 'stored':
return 'credential stored claw-side through this surface'
case 'profile':
return 'credential from the stored rhema config'
default:
return 'serves without a credential'
}
}
// A login landed: the claw answered with a new state object (a failed
// one leaves the old, and the banner names the error), so the form
// forgets what it held and closes.
watch(
() => props.state,
(state, previous) => {
if (pendingAdd.value !== null && state !== previous) {
pendingAdd.value = null
loginOpen.value = false
addName.value = ''
addApiKey.value = ''
addUrl.value = ''
addKind.value = ''
}
},
)
</script>
<template>
<div class="page">
<PageHeader title="Model providers" subtitle="The backends the claw can answer with. Logins apply immediately; the pair switch persists.">
<template #actions>
<button :disabled="mutating" @click="loginOpen = true">Add provider</button>
<button :disabled="!dirty || saving" @click="discard">Discard</button>
<button class="primary" :disabled="!dirty || saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</template>
</PageHeader>
<div class="scroll">
<AppCallout v-if="dirty" variant="warn" title="1 change not applied">
The staged pair takes effect on the claw's next turn, no restart
involved. Save to apply it, Discard to keep the running backend.
</AppCallout>
<p v-if="!state" class="muted">loading providers…</p>
<template v-else>
<SettingCard title="Primary model" meta="what the agent answers with">
<SettingRow
title="Provider"
description="The backend instance the claw dispatches turns on."
>
<select
:value="draftProvider ?? undefined"
:disabled="saving"
@change="pickProvider(($event.target as HTMLSelectElement).value)"
>
<option v-for="p in state.providers" :key="p.id" :value="p.id">
{{ p.id }}
</option>
</select>
</SettingRow>
<SettingRow
title="Model"
description="Left on the default, the provider's own model answers."
>
<select v-model="draftModel" :disabled="saving || !draftProvider">
<option value="">(provider default)</option>
<option v-for="m in models" :key="m.id" :value="m.id">
{{ m.id }}
</option>
</select>
</SettingRow>
</SettingCard>
<SettingCard
title="Available backends"
:meta="`${state.providers.length} constructed · env-backed kinds return on restart`"
>
<div
v-for="p in state.providers"
:key="p.id"
class="backend"
:class="{ staged: p.id === draftProvider, active: p.id === state.active.provider }"
role="button"
tabindex="0"
@click="pickProvider(p.id)"
@keydown.enter="pickProvider(p.id)"
>
<span class="kind mono">{{ p.id }}</span>
<AppBadge
v-if="p.kind !== p.id"
variant="dim"
:title="`a ${p.kind} backend`"
>{{ p.kind }}</AppBadge>
<span class="model mono">{{ p.default_model }}</span>
<AppBadge
v-if="p.source"
variant="dim"
:title="sourceTitle(p.source)"
>{{ p.source }}</AppBadge>
<AppBadge v-if="p.id === state.active.provider" variant="accent">active</AppBadge>
<AppBadge v-else-if="p.id === draftProvider && dirty">staged</AppBadge>
<button
class="ghost remove"
:title="`remove ${p.id}`"
:disabled="mutating || state.providers.length <= 1"
@click.stop="emit('remove', p.id)"
>
×
</button>
</div>
</SettingCard>
</template>
</div>
<AppModal :open="loginOpen" title="Add a provider" @close="loginOpen = false">
<div v-if="state" class="add-form">
<div class="add-row">
<label class="add-label" for="add-kind">Kind</label>
<select id="add-kind" v-model="addKind" :disabled="mutating">
<option value="" disabled>choose a provider…</option>
<option v-for="entry in addable" :key="entry.kind" :value="entry.kind">
{{ kindLabel(entry.kind) }}{{ state.providers.some((p) => p.id === entry.kind) ? '; replaces its credentials' : '' }}
</option>
</select>
</div>
<div class="add-row">
<label class="add-label" for="add-name">Name</label>
<input
id="add-name"
v-model="addName"
type="text"
placeholder="name (optional, defaults to the provider kind)"
autocomplete="off"
:disabled="mutating"
class="mono"
/>
</div>
<div v-if="declared('url')" class="add-row">
<label class="add-label" for="add-url">{{ label('url', 'Server URL') }}</label>
<input
id="add-url"
v-model="addUrl"
type="text"
placeholder="http://127.0.0.1:8080"
autocomplete="off"
:disabled="mutating"
class="mono"
/>
</div>
<div v-if="declared('api_key')" class="add-row">
<label class="add-label" for="add-key">{{ label('api_key', keyLabel) }}</label>
<input
id="add-key"
v-model="addApiKey"
type="password"
placeholder="stored claw-side, never shown again"
autocomplete="off"
:disabled="mutating"
/>
</div>
<p v-if="addName.trim() && !nameValid" class="add-note">
a name carries no whitespace or '/' and stays under 64 characters.
</p>
<p v-else-if="addIdPresent" class="add-note">
{{ resultingId }} is already serving. saving replaces its
credentials and restarts nothing; the active pair is untouched.
</p>
<p v-else-if="addKind" class="add-note">
saving adds a new {{ kindLabel(addKind) }} backend{{ addName.trim() ? ` named ${resultingId}` : '' }}.
</p>
</div>
<template #actions>
<button @click="loginOpen = false">Cancel</button>
<button class="primary" :disabled="!addReady || mutating" @click="submitAdd">
{{ mutating ? 'Working…' : addIdPresent ? 'Replace credentials' : 'Add provider' }}
</button>
</template>
</AppModal>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
flex: 1;
}
.scroll {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 860px;
}
.muted {
color: var(--text3);
padding: 8px 2px;
}
.backend {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 16px;
cursor: pointer;
}
.backend:hover {
background: var(--elevated);
}
.backend.staged {
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
}
.backend .kind {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.backend .model {
color: var(--text3);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.backend .remove {
flex: none;
padding: 0 6px;
font-size: 14px;
}
.add-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.add-row {
display: flex;
align-items: center;
gap: 12px;
}
.add-label {
width: 110px;
flex: none;
color: var(--text3);
font-size: 12.5px;
}
.add-row select,
.add-row input {
flex: 1;
min-width: 0;
}
.add-note {
margin: 0;
color: var(--text3);
font-size: 12.5px;
}
.add-actions {
display: flex;
align-items: center;
gap: 12px;
}
.add-actions .callout {
flex: 1;
}
@media (max-width: 760px) {
.add-row {
flex-direction: column;
align-items: stretch;
}
.add-label {
width: auto;
}
}
</style>