Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826<script lang="ts"> import { onMount, type Snippet } from 'svelte'; import { parse, suggest, type Command } from '../commands'; import { attachable, marker, MAX_IMAGES, readImage, referenced, resolve, unmark, type Attached } from './attachments'; import type { Draft } from './drafts'; import Fold from '../Fold.svelte';
// Enter sends, Shift-Enter starts a new line. A draft survives until it is // sent, in `draft`, which outlives this composer. Images come by paste, // drop or the picker; each sits inline in the sentence as a 36×28 tile // that serializes back to `[Image #n]`. let { draft, onsend, onpopout, onstop, busy, compact = false, commands = [], oncommand, oncommands, placeholder = 'message albedo…', attached = 0, tools, }: { /** What was typed and not sent, kept by the chat so another board does not lose it. */ draft: Draft; onsend: (text: string, images: Attached[]) => void; onpopout?: (text: string) => void; onstop?: () => Promise<void>; busy: boolean; /** Phone composer: compact floating beam with mobile font sizing. */ compact?: boolean; /** The session's slash commands; a line starting with one runs it instead of sending. */ commands?: Command[]; oncommand?: (command: Command, rest: string) => void; /** Refresh when autocomplete opens or a slash draft regains focus. */ oncommands?: () => void; placeholder?: string; /** How many notes go with the next message besides the draft; with any, an empty draft can be sent. */ attached?: number; /** Chips in a row under the field, after the picture button (the full-screen chat's model, jobs and terminal). */ tools?: Snippet; } = $props(); let stopping = $state(false); let toolsWidth = $state(0);
async function stop() { if (!onstop || stopping) return; stopping = true; try { await onstop(); } catch (error) { notice = `couldn't stop: ${(error as Error).message ?? error}`; } finally { stopping = false; } } // svelte-ignore state_referenced_locally let text = $state(draft.text); $effect(() => { draft.text = text; }); let editor = $state<HTMLDivElement>(); let picker = $state<HTMLInputElement>(); let dragging = $state(false); let notice = $state(''); let hovered = $state<{ n: number; left: number; bottom: number } | null>(null); let hoverTimer: ReturnType<typeof setTimeout> | undefined; // svelte-ignore state_referenced_locally const images = draft.images;
const marked = $derived(referenced(text, images)); // While the name is being typed, the commands it could be. const completing = $derived(!!oncommand && /^\/\S*$/.test(text)); const menu = $derived(completing ? suggest(commands, text).slice(0, 8) : []); $effect(() => { if (completing) oncommands?.(); }); let highlighted = $state(0); $effect(() => { menu; highlighted = 0; });
const ready = $derived(text.trim().length > 0 || marked.length > 0 || attached > 0); const notes = $derived(`${attached} ${attached === 1 ? 'note' : 'notes'}`);
const MARKER_SPLIT = /(\[Image #\d+\])/g; const MARKER_EXACT = /^\[Image #(\d+)\]$/;
function makeTile(n: number, image: Attached): HTMLSpanElement { const tile = document.createElement('span'); tile.className = 'inline-tile'; tile.contentEditable = 'false'; tile.dataset.image = String(n); const img = document.createElement('img'); img.src = image.preview; img.alt = marker(n); const num = document.createElement('span'); num.className = 'tile-num'; num.textContent = `#${n}`; tile.append(img, num); tile.addEventListener('pointerenter', () => showHover(n, tile)); tile.addEventListener('pointerleave', scheduleHideHover); tile.addEventListener('click', (event) => { event.stopPropagation(); if (hovered?.n === n) hovered = null; else showHover(n, tile); }); return tile; }
function showHover(n: number, tile: HTMLElement) { clearTimeout(hoverTimer); if (!editor) return; const composer = editor.closest<HTMLElement>('.composer'); const host = composer?.getBoundingClientRect(); const rect = tile.getBoundingClientRect(); if (!composer || !host) return; const scale = composer.offsetWidth ? host.width / composer.offsetWidth : 1; const center = (rect.left - host.left + rect.width / 2) / scale; const clamped = Math.max(82, Math.min(composer.offsetWidth - 82, center)); const bottom = (host.bottom - rect.top) / scale + 8; hovered = { n, left: clamped, bottom }; }
function scheduleHideHover() { clearTimeout(hoverTimer); hoverTimer = setTimeout(() => (hovered = null), 140); }
/** Serializes the contenteditable DOM back into plain text + `[Image #n]` markers. */ function serialize(root: Node): string { let out = ''; const children = root.childNodes; for (let i = 0; i < children.length; i++) { const node = children[i]; if (node.nodeType === Node.TEXT_NODE) { out += (node.textContent ?? '').replace(/ /g, ' '); } else if (node instanceof HTMLElement) { const imgN = node.dataset.image; if (imgN) { out += marker(Number(imgN)); } else if (node.tagName === 'BR') { // A trailing <br> added by the browser at the end of a block is not an extra newline. if (i < children.length - 1 || root !== editor) out += '\n'; } else if (node.tagName === 'DIV' || node.tagName === 'P') { if (out.length > 0 && !out.endsWith('\n')) out += '\n'; out += serialize(node); } else { out += serialize(node); } } } return out; }
/** Renders `value` (including `[Image #n]` tiles) into the contenteditable element. */ function renderToDom(value: string, focusEnd = false) { if (!editor) return; editor.replaceChildren(); if (value) { for (const part of value.split(MARKER_SPLIT)) { if (!part) continue; const match = MARKER_EXACT.exec(part); const n = match ? Number(match[1]) : 0; const image = n ? images.get(n) : undefined; if (n && image) editor.append(makeTile(n, image)); else editor.append(document.createTextNode(part)); } } if (focusEnd) placeCaretAtEnd(); }
function placeCaretAtEnd() { if (!editor) return; editor.focus(); const sel = document.getSelection(); if (!sel) return; const range = document.createRange(); range.selectNodeContents(editor); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); }
function onInput() { if (!editor) return; const next = serialize(editor); // When everything is deleted, the browser leaves a lone <br> that pushes // ::after onto a second line; clearing it keeps the empty beam 1 line tall. if (!next && editor.childNodes.length) editor.replaceChildren(); text = next; if (hovered && !images.has(hovered.n)) hovered = null; }
onMount(() => { renderToDom(text, false); const show = (next: string) => setText(next, false); draft.show = show; return () => { clearTimeout(hoverTimer); if (draft.show === show) draft.show = null; }; });
function setText(next: string, focusEnd = false) { text = next; renderToDom(next, focusEnd); }
function complete(command: Command) { const next = command.slash_name + (command.arguments.length || commands.some((other) => other.slash_name === command.slash_name && other.arguments.length) ? ' ' : ''); setText(next, true); }
function send() { if (!ready) return; const typed = oncommand && !marked.length ? parse(commands, text) : null; if (typed) { oncommand!(typed.command, typed.rest); setText('', false); notice = ''; hovered = null; return; } const message = resolve(text, images); onsend(message.text, message.images); images.clear(); draft.next = 0; notice = ''; hovered = null; setText('', true); }
async function attach(files: Iterable<File>) { const chosen = [...files].filter(attachable); if (!chosen.length) return; const room = MAX_IMAGES - marked.length; notice = chosen.length > room ? `a message carries at most ${MAX_IMAGES} images` : ''; for (const file of chosen.slice(0, Math.max(0, room))) { const image = await readImage(file); const n = ++draft.next; images.set(n, image); insertTile(n, image); } }
/** Inserts an inline tile at the caret (or at the end of the field), spaced from neighbours. */ function insertTile(n: number, image: Attached) { if (!editor) { const prefix = text && !/\s$/.test(text) ? ' ' : ''; text = `${text}${prefix}${marker(n)} `; return; } editor.focus(); const sel = document.getSelection(); const tile = makeTile(n, image); const after = document.createTextNode(' '); if (sel && sel.rangeCount > 0 && editor.contains(sel.anchorNode)) { const range = sel.getRangeAt(0); range.deleteContents(); const before = range.startContainer.nodeType === Node.TEXT_NODE ? (range.startContainer.textContent ?? '').slice(0, range.startOffset) : ''; if (before && !/\s$/.test(before)) { const lead = document.createTextNode(' '); range.insertNode(lead); range.setStartAfter(lead); range.collapse(true); } range.insertNode(tile); range.setStartAfter(tile); range.collapse(true); range.insertNode(after); range.setStartAfter(after); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } else { if (editor.childNodes.length && !(editor.lastChild?.textContent ?? '').endsWith(' ')) { editor.append(document.createTextNode(' ')); } editor.append(tile, after); placeCaretAtEnd(); } text = serialize(editor); }
/** Inserts plain text at the current caret inside the contenteditable field. */ function insertPlainText(plain: string) { if (!editor) return; editor.focus(); const sel = document.getSelection(); if (!sel) return; const node = document.createTextNode(plain); if (!sel.rangeCount || !editor.contains(sel.anchorNode)) editor.append(node); else { const range = sel.getRangeAt(0); range.deleteContents(); range.insertNode(node); } // A newline last in the field draws no line until something follows it; a trailing <br> (which serialize skips) does. let after = node.nextSibling; while (after instanceof Text && !after.length) after = after.nextSibling; if (plain.endsWith('\n') && !after) editor.append(document.createElement('br')); const range = document.createRange(); range.setStart(node, node.length); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); text = serialize(editor); }
function remove(n: number) { hovered = null; images.delete(n); setText(unmark(text, n), true); }
function keydown(event: KeyboardEvent) { if (menu.length) { const exact = menu[highlighted]?.slash_name === text; if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); highlighted = (highlighted + (event.key === 'ArrowDown' ? 1 : menu.length - 1)) % menu.length; return; } if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey && !exact)) { event.preventDefault(); complete(menu[highlighted]); return; } if (event.key === 'Escape') { event.preventDefault(); setText('', false); return; } } // Escape in an empty field stops the turn, as in the terminal; otherwise it leaves the field so canvas shortcuts work. if (event.key === 'Escape') { event.preventDefault(); if (busy && !text) stop(); else editor?.blur(); return; } if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); send(); return; } if (event.key === 'Enter' && event.shiftKey && !event.isComposing) { event.preventDefault(); insertPlainText('\n'); } }
function paste(event: ClipboardEvent) { const files = [...(event.clipboardData?.files ?? [])].filter(attachable); if (files.length) { event.preventDefault(); attach(files); return; } const plain = event.clipboardData?.getData('text/plain'); if (plain !== undefined) { event.preventDefault(); insertPlainText(plain); } }
function drop(event: DragEvent) { dragging = false; if (!event.dataTransfer?.files.length) return; event.preventDefault(); attach(event.dataTransfer.files); }
// Keep DOM in sync if draft.text is reset externally. $effect(() => { if (editor && text !== serialize(editor)) { renderToDom(text, false); } });</script>
<form class="composer" class:compact class:stacked={!!tools} class:dragging onsubmit={(event) => (event.preventDefault(), send())} ondragover={(event) => { if (!event.dataTransfer?.types.includes('Files')) return; event.preventDefault(); dragging = true; }} ondragleave={() => (dragging = false)} ondrop={drop}> {#if menu.length} <ul class="commands" role="listbox" aria-label="commands"> {#each menu as command, index (command.slash_name)} <li role="option" aria-selected={index === highlighted}> <button type="button" tabindex="-1" onpointerdown={(event) => event.preventDefault()} onclick={() => complete(command)} onpointerenter={() => (highlighted = index)}> <span class="slash">{command.slash_name}</span> <span class="what">{command.description}</span> </button> </li> {/each} </ul> {/if}
{#if hovered && images.has(hovered.n)} <div class="tile-loupe" role="group" aria-label="attached image {hovered.n}" style:left="{hovered.left}px" style:bottom="{hovered.bottom}px" onpointerenter={() => clearTimeout(hoverTimer)} onpointerleave={scheduleHideHover} > <img src={images.get(hovered.n)!.preview} alt={marker(hovered.n)} /> <span class="loupe-num">#{hovered.n}</span> <button type="button" class="loupe-drop" aria-label="remove image {hovered.n}" title="remove" onclick={() => remove(hovered!.n)}> <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg> </button> </div> {/if}
<div bind:this={editor} class="editor" class:empty={!text && !marked.length} class:with-popout={!!onpopout} class:with-stop={busy && !!onstop} class:with-notes={attached > 0} contenteditable="true" role="textbox" aria-multiline="true" aria-label="message" data-placeholder={placeholder} spellcheck="true" tabindex="0" oninput={onInput} onfocus={() => completing && oncommands?.()} onkeydown={keydown} onpaste={paste} ></div>
<input bind:this={picker} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onchange={(event) => { const input = event.currentTarget; attach(input.files ?? []).then(() => (input.value = '')); }} />
<div class="actions"> <button type="button" class="tool" aria-label="attach images" title="attach images (or paste, or drop)" onclick={() => picker?.click()}> <svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><rect x="2" y="3" width="12" height="10" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.3" /><circle cx="6" cy="6.6" r="1.2" fill="currentColor" /><path d="M2.6 11.6 6 8.6l2.4 2 2.2-1.8 2.8 2.6" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" /></svg> </button> {#if tools}<span class="tools" bind:clientWidth={toolsWidth}><Fold room={toolsWidth} up label="Chat tools">{@render tools()}</Fold></span>{/if} {#if onpopout} <button type="button" class="tool" aria-label="pop out into a note" title="pop out into a note on the canvas" onclick={() => { onpopout(text); setText('', false); }} > <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M9 3h4v4M13 3 8 8M11.5 9.5V12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5.5a1 1 0 0 1 1-1h2.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" /></svg> </button> {/if} {#if busy && onstop} <button type="button" class="stop" disabled={stopping} aria-label="Stop" title="stop albedo (Esc)" onclick={stop}> <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><rect x="3.5" y="3.5" width="9" height="9" rx="2" fill="currentColor" /></svg> </button> {/if} <button type="submit" class="send" class:notes={attached > 0} disabled={!ready} aria-label={attached ? `Send with ${notes}` : 'Send'} title={attached ? `send with ${notes} (Enter)` : 'Send (Enter)'}> {#if attached}<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path d="M3 3.5h10v7H7l-3 2.5v-2.5H3z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" /></svg><span>{attached}</span>{/if} <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M8 13V3.5M3.5 8 8 3.5 12.5 8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" /></svg> </button> </div>
{#if notice}<span class="hint">{notice}</span>{/if}</form>
<style> .commands { position: absolute; right: 0; bottom: calc(100% + 8px); left: 0; z-index: 10; display: grid; gap: 1px; margin: 0; padding: 6px; list-style: none; background: var(--surface); border-radius: 14px; box-shadow: var(--shadow-lg); } .commands li { min-width: 0; } .commands button { display: flex; overflow: hidden; align-items: baseline; gap: 10px; width: 100%; padding: 6px 10px; color: var(--text); font: inherit; font-size: calc(13px * var(--canvas-scale)); text-align: left; background: none; border: 0; border-radius: 8px; cursor: pointer; } [aria-selected='true'] button { background: var(--hover); } .slash { flex: none; font-family: var(--mono); font-size: calc(12.5px * var(--canvas-scale)); } .what { min-width: 0; overflow: hidden; color: var(--muted); font-size: calc(12px * var(--canvas-scale)); white-space: nowrap; text-overflow: ellipsis; } .composer { position: relative; display: block; padding: 7px 12px; background: var(--field); border-radius: 14px; transition: box-shadow 120ms ease; } .composer.compact { padding: 7px 12px; border-radius: 16px; box-shadow: var(--shadow); } .composer:focus-within { box-shadow: 0 0 0 2px var(--focus-ring); } .composer.dragging { box-shadow: 0 0 0 2px var(--accent); }
/* 100%-width text container: upper lines span the full width of the beam, and the trailing inline ::after spacer reserves space ONLY at the end of the last line for the bottom-right buttons. */ .editor { display: block; width: 100%; min-height: 24px; max-height: 240px; margin: 0; padding: 1px 0; overflow-y: auto; color: var(--text); font: inherit; font-size: calc(14px * var(--canvas-scale)); line-height: 24px; white-space: pre-wrap; overflow-wrap: anywhere; outline: 0; } .compact .editor { /* Below 16px, iOS zooms the page when the field takes focus. */ font-size: max(16px, calc(16px * var(--canvas-scale))); } .editor.empty::before { content: attr(data-placeholder); color: var(--faint); pointer-events: none; } /* Reserves space ONLY at the end of the last line for the bottom-right buttons. */ .editor::after { content: ''; display: inline-block; width: 64px; height: 24px; vertical-align: bottom; } .editor.with-popout::after { width: 92px; } .editor.with-stop::after { width: 94px; } .editor.with-popout.with-stop::after { width: 122px; } .editor.with-notes::after { width: 118px; }
/* Inline 36×28 image tiles on tight line-height (-3px vertical margin). */ .editor :global(.inline-tile) { position: relative; display: inline-block; width: 36px; height: 28px; margin: -3px 5px -3px 2px; vertical-align: middle; border-radius: 6.5px; box-shadow: 0 1px 3px rgb(0 0 0 / 0.34), inset 0 0 0 1px rgb(255 255 255 / 0.16); cursor: pointer; user-select: none; transition: transform 120ms ease, box-shadow 120ms ease; } .editor :global(.inline-tile:hover) { transform: translateY(-1px); box-shadow: 0 2px 6px rgb(0 0 0 / 0.45), 0 0 0 1.5px var(--accent); } .editor :global(.inline-tile > img) { display: block; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; } .editor :global(.tile-num) { position: absolute; right: -3px; bottom: -2px; min-width: 14px; height: 13px; padding: 0 3px; color: var(--text); font-family: var(--mono); font-size: 9px; font-weight: 700; line-height: 13px; text-align: center; background: var(--surface); border-radius: 4.5px; box-shadow: 0 1px 3px rgb(0 0 0 / 0.5), 0 0 0 1px color-mix(in oklab, var(--text) 14%, transparent); pointer-events: none; }
/* Tight photo frame hover loupe (H1) with corner #n and top-right remove × */ .tile-loupe { position: absolute; z-index: 25; padding: 3px; background: var(--surface); border-radius: 10px; box-shadow: var(--shadow-lg); transform: translateX(-50%); } .tile-loupe img { display: block; width: 148px; height: 98px; object-fit: cover; border-radius: 7px; } .loupe-num { position: absolute; bottom: 7px; left: 8px; padding: 0 5px; color: #fff; font-family: var(--mono); font-size: 10px; font-weight: 600; line-height: 16px; background: rgb(0 0 0 / 0.6); border-radius: 4px; backdrop-filter: blur(4px); pointer-events: none; } .loupe-drop { position: absolute; top: -6px; right: -6px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; color: var(--surface); background: var(--text); border: 0; border-radius: 50%; box-shadow: 0 2px 6px rgb(0 0 0 / 0.4); cursor: pointer; } .loupe-drop:hover { background: var(--danger); }
/* Bottom-right action cluster */ .actions { position: absolute; right: 6px; bottom: 5px; display: flex; align-items: center; gap: 2px; } .tool { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; color: var(--muted); background: none; border: 0; border-radius: 8px; cursor: pointer; transition: background-color 120ms ease, color 120ms ease; } .tool:hover { color: var(--text); background: var(--hover); } .stop, .send { display: inline-flex; align-items: center; justify-content: center; min-width: 28px; height: 28px; padding: 0 7px; border: 0; border-radius: 8px; cursor: pointer; transition: opacity 120ms ease, transform 100ms ease, background-color 120ms ease; } .stop { color: var(--danger); background: color-mix(in oklab, var(--danger) 16%, var(--well)); } .stop:hover:not(:disabled) { background: color-mix(in oklab, var(--danger) 24%, var(--well)); } .send { gap: 4px; color: var(--accent); font: inherit; font-size: calc(12px * var(--canvas-scale)); font-weight: 600; font-variant-numeric: tabular-nums; background: color-mix(in oklab, var(--accent) 18%, var(--well)); } .send:hover:not(:disabled) { background: color-mix(in oklab, var(--accent) 26%, var(--well)); } .send.notes { padding: 0 9px; } .stop:active:not(:disabled), .send:active:not(:disabled) { transform: scale(0.95); } .stop:disabled, .send:disabled { color: var(--faint); background: transparent; opacity: 0.45; cursor: default; } /* With tools: the field takes the whole width and the buttons sit in a row under it, the chips after the picture and send at the far end. */ .composer.stacked { padding: 10px 8px 8px 14px; border-radius: 20px; } .stacked .editor { min-height: 26px; padding: 2px 6px 8px 0; } .stacked .editor::after { display: none; } .stacked .actions { position: static; gap: 4px; margin-left: -6px; } .tools { --fold-gap: 4px; display: flex; flex: 1; align-items: center; gap: 4px; min-width: 0; opacity: 0.6; transition: opacity 260ms ease; } .composer:hover .tools, .composer:focus-within .tools { opacity: 1; transition-duration: 120ms; } .hint { display: block; margin-top: 4px; color: var(--danger); font-size: calc(11.5px * var(--canvas-scale)); }</style>