Something went wrong. Try again.
small gleam coding and (not yet) persistent agent daemon with a detachable cli forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767<script lang="ts"> import type { SelectedLineRange } from '@pierre/diffs'; import { untrack, type Component } from 'svelte'; import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { ApiError, readChanges, type CheckoutChanges, type HistoryEntry } from '../api'; import { namer } from '../burst'; import { FILE_CHANGE, fromCheckout, fromTurn, partChange, type ChangedFile, type Scope } from '../changeset'; import { busy } from '../live.svelte'; import { prefs } from '../prefs.svelte'; import { readTurn, turnChanges } from '../review'; import { sessions } from '../sessions.svelte'; import FileTree from './FileTree.svelte'; import FileView from './FileView.svelte';
// The highlighter is large; it loads with the first diff. const DiffView = import('./DiffView.svelte').then((module) => module.default); let View = $state.raw<Component<{ key: string; path: string; change: number; diff: string; oncomment?: (range: SelectedLineRange) => void }>>();
// A session's diff client: one turn's changes, or the checkout's against // HEAD or its upstream, read top to bottom with the files beside them as // an index that follows the scroll; the project's tree on the other side // of that rail; and any file open in a tab of its own. Notes go out on the // canvas, as the window that holds this decides (`oncomment`). let { sessionId, workspace, turn, scope, onscope, jump, keyOf, oncomment, count = () => 0, onfiles, }: { sessionId: string; workspace: string; turn: { turnId: string; from: number } | null; scope: Scope; onscope: (scope: Scope) => void; jump?: { path: string; at: number }; keyOf: (path: string, change: number) => string; oncomment?: (path: string, change: number, range: SelectedLineRange) => void; count?: (path: string) => number; onfiles?: (files: ChangedFile[]) => void; } = $props();
const SCOPES: { scope: Scope; label: string }[] = [ { scope: 'turn', label: 'this turn' }, { scope: 'uncommitted', label: 'uncommitted' }, { scope: 'upstream', label: 'vs upstream' }, ]; const LETTER: Record<ChangedFile['status'], string> = { modified: 'M', added: 'A', deleted: 'D', renamed: 'R', untracked: 'U' }; const POLL_MS = 2500;
let files = $state.raw<ChangedFile[] | null>(null); let checkout = $state.raw<CheckoutChanges | null>(null); let problem = $state(''); let side = $state<'changes' | 'files'>('changes'); let rail = $state(false); let tabs = $state<string[]>([]); let active = $state<string | null>(null); let inView = $state<string | null>(null); const read = new SvelteSet<string>(); const rendered = new SvelteMap<string, boolean>(); let stream = $state<HTMLElement>();
const root = $derived(checkout?.checkout.root ?? workspace); const name = $derived(namer(root)); const working = $derived(busy(sessions.byId.get(sessionId))); const shown = $derived(files ?? []); const unread = $derived(shown.filter((file) => !read.has(file.path)).length); const totals = $derived(shown.reduce((sum, file) => [sum[0] + file.added, sum[1] + file.removed], [0, 0])); const scopes = $derived(SCOPES.filter((item) => item.scope !== 'turn' || turn));
/** A path as the daemon's file routes take it: absolute, or under the checkout's root. */ function absolute(path: string) { return path.startsWith('/') || /^[^/]+:/.test(path) ? path : `${root}/${path}`; } const marks = $derived(new Map(shown.map((file) => [absolute(file.path), LETTER[file.status]]))); const changedFile = $derived(new Map(shown.map((file) => [absolute(file.path), file])));
// What came after the entries already read, for a turn followed while it runs. let entries: HistoryEntry[] = []; const hydrated = new Map<string, HistoryEntry>(); let etag = ''; let reading: Promise<void> | null = null; // Which scope, turn and session a read is for: one that started before a // change of them must not land its results in the new one. let generation = 0;
function refresh() { reading ??= (async () => { const asked = generation; try { if (scope === 'turn' && turn) { const after = entries.at(-1)?.position; const arrived = await (after === undefined ? readTurn(sessionId, turn.turnId, turn.from) : readTurn(sessionId, turn.turnId, after + 1, false)); if (asked !== generation || (entries.length && !arrived.length)) return; entries = [...entries, ...arrived]; const [found, loaded] = await Promise.all([turnChanges(entries, hydrated), DiffView]); if (asked !== generation) return; View = loaded; checkout = null; files = fromTurn(found); } else if (scope !== 'turn') { const [got, loaded] = await Promise.all([readChanges(sessionId, scope, etag), DiffView]); if (asked !== generation) return; View = loaded; if (got) { etag = got.etag; checkout = got.changes; files = fromCheckout(got.changes); } } problem = ''; onfiles?.(files ?? []); } catch (failure) { if (asked !== generation) return; problem = failure instanceof ApiError && failure.code === 'not_a_repository' ? "this folder isn't in a git or jj checkout" : failure instanceof ApiError && failure.code === 'no_upstream' ? `nothing to compare with: ${failure.message}` : String((failure as Error)?.message ?? failure); } finally { reading = null; if (asked !== generation) refresh(); } })(); return reading; }
// A new scope, turn or session starts over. The window hands over a new // `turn` object whenever its data changes (a jump to another file), so // this follows the id, which only changes for another turn. const turnId = $derived(turn?.turnId); $effect(() => { sessionId; scope; turnId; untrack(() => { generation++; entries = []; hydrated.clear(); etag = ''; files = null; read.clear(); refresh(); }); });
// While the session works its changes may grow: read again every few // seconds, and once more when it stops. $effect(() => { if (!working) { untrack(refresh); return; } const timer = setInterval(refresh, POLL_MS); return () => clearInterval(timer); });
/** Lands on a file in the stream, without drawing anything again. */ function goto(path: string) { active = null; rail = false; inView = path; requestAnimationFrame(() => { const card = stream?.querySelector<HTMLElement>(`[data-file="${CSS.escape(path)}"]`); if (card && stream) stream.scrollTo({ top: card.offsetTop, behavior: 'instant' }); }); }
/** The file the top of the stream is in, for the index to light: read once a frame, not on every scroll event. */ let following = 0; function follow() { if (following) return; following = requestAnimationFrame(() => { following = 0; if (!stream) return; const top = stream.scrollTop + 40; const card = [...stream.querySelectorAll<HTMLElement>('[data-file]')].find((element) => element.offsetTop + element.offsetHeight > top); inView = card?.dataset.file ?? null; }); }
function open(path: string) { if (!tabs.includes(path)) tabs = [...tabs, path]; active = path; rail = false; if (!rendered.has(path) && path.endsWith('.md')) rendered.set(path, true); }
function close(path: string) { const at = tabs.indexOf(path); tabs = tabs.filter((tab) => tab !== path); if (active === path) active = tabs[Math.max(0, at - 1)] ?? null; }
// Asked from a chat: bring that file into view once, in whatever scope holds it. let jumped: number | undefined; $effect(() => { const at = jump?.at; if (!jump || at === undefined || at === jumped || !files) return; // A chat names files as the session saw them, which may spell the // checkout's folder differently (/tmp against /private/tmp on a Mac). const wanted = absolute(jump.path); const file = files.find((item) => absolute(item.path) === wanted) ?? files.find((item) => wanted.endsWith(`/${item.path}`)); if (!file) return; jumped = at; untrack(() => goto(file.path)); });
function setScope(next: Scope) { if (next !== scope) onscope(next); active = null; }
// Keys work while the client has focus (a press anywhere in it gives it focus), // and stop there, so the board's own shortcuts (n for a note) don't also fire. function keydown(event: KeyboardEvent) { if (event.metaKey || event.ctrlKey || event.altKey || (event.target as HTMLElement).closest('input, textarea, [contenteditable]')) return; if (!act(event.key)) return; event.preventDefault(); event.stopPropagation(); }
/** Does what `key` asks, and says whether the key is the client's. */ function act(key: string): boolean { if (key === 'w') { prefs.wrapCode = !prefs.wrapCode; return true; } if (key === '[' || key === ']') { const order = [null, ...tabs]; const at = order.indexOf(active); active = order[(at + (key === ']' ? 1 : order.length - 1)) % order.length]; return true; } if (active !== null) { if (key !== 'Escape') return false; active = null; return true; } const at = shown.findIndex((file) => file.path === inView); const here = shown[Math.max(0, at)]; if (key === 'j' || key === 'k') { const next = shown[Math.min(shown.length - 1, Math.max(0, at + (key === 'j' ? 1 : -1)))]; if (next) goto(next.path); } else if (key === 'n' || key === 'p') hunk(key === 'n' ? 1 : -1); else if (key === 'v' && here) { if (read.has(here.path)) read.delete(here.path); else read.add(here.path); } else if (key === 'o' && here && here.status !== 'deleted') open(absolute(here.path)); else return 'jknpvo'.includes(key); return true; }
/** Scrolls to the next or previous run of changed lines, across files. */ function hunk(direction: 1 | -1) { if (!stream) return; // On a zoomed canvas the window is scaled: measure in its own pixels. const scale = stream.clientHeight ? stream.getBoundingClientRect().height / stream.clientHeight : 1; const origin = stream.getBoundingClientRect().top; const tops: number[] = []; for (const host of stream.querySelectorAll('diffs-container')) { let before = false; for (const row of host.shadowRoot?.querySelectorAll<HTMLElement>('[data-line]') ?? []) { const changed = /addition|deletion/.test(row.dataset.lineType ?? ''); if (changed && !before) tops.push(stream.scrollTop + (row.getBoundingClientRect().top - origin) / scale); before = changed; } } // A diff far from the view has not mounted, so its changes are not rows // yet: its top stands in for them, and landing there mounts it. for (const waiting of stream.querySelectorAll<HTMLElement>('.diff[data-waiting]')) tops.push(stream.scrollTop + (waiting.getBoundingClientRect().top - origin) / scale); tops.sort((a, b) => a - b); const from = stream.scrollTop + 60; const target = direction > 0 ? tops.find((top) => top > from + 1) : tops.findLast((top) => top < from - 1); if (target !== undefined) stream.scrollTo({ top: target - 60, behavior: 'instant' }); }
/** The lines of a file this checkout changed, for marking a Markdown document's blocks. */ function addedLines(path: string): Set<number> { const file = changedFile.get(path); const lines = new Set<number>(); for (const part of file?.parts ?? []) { let next = 0; for (const line of part.diff.split('\n')) { const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line); if (hunk) next = Number(hunk[1]); else if (line.startsWith('+') && !line.startsWith('+++')) lines.add(next++); else if (line.startsWith(' ')) next++; } } return lines; }</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions (the client takes keys while focused) --><div class="client" class:rail-open={rail} tabindex="-1" role="region" aria-label="changes" onkeydown={keydown}> <aside class="side" aria-label="files"> <div class="side-head"> <div class="seg" role="tablist"> <button role="tab" aria-selected={side === 'changes'} class:on={side === 'changes'} onclick={() => (side = 'changes')}>changes {#if files}<span class="num">{shown.length}</span>{/if}</button> <button role="tab" aria-selected={side === 'files'} class:on={side === 'files'} onclick={() => (side = 'files')}>files</button> </div> </div> <div class="side-list"> {#if side === 'files'} <FileTree {root} {marks} {active} onopen={open} /> {:else} {#each shown as file (file.path)} <button class="row" class:on={active === null && inView === file.path} class:read={read.has(file.path)} title={file.path} onclick={() => goto(file.path)}> <span class="badge s-{file.status}">{read.has(file.path) ? '✓' : LETTER[file.status]}</span> <span class="fname">{file.path.split('/').pop()}</span> <span class="fdir">{name(absolute(file.path)).split('/').slice(0, -1).join('/')}</span> {#if count(file.path)}<span class="said">{count(file.path)} ✎</span>{/if} <span class="counts"><span class="added">+{file.added}</span> <span class="removed">−{file.removed}</span></span> </button> {/each} {/if} </div> </aside>
<section class="main"> <nav class="tabs" aria-label="open files"> <button class="rail-toggle" aria-label="files" aria-expanded={rail} onclick={() => (rail = !rail)}>☰</button> <button class="tab" class:on={active === null} onclick={() => (active = null)}>changes {#if files}<span class="num">{shown.length}</span>{/if}</button> {#each tabs as tab (tab)} <div class="tab" class:on={active === tab}> <button class="tab-name" title={tab} onclick={() => (active = tab)}>{tab.split('/').pop()}</button> <button class="tab-close" aria-label="close {tab.split('/').pop()}" onclick={() => close(tab)}>×</button> </div> {/each} <span class="grow"></span> <button class="wrap" aria-pressed={prefs.wrapCode} title="wrap long lines (w)" onclick={() => (prefs.wrapCode = !prefs.wrapCode)}>wrap</button> </nav>
{#if active === null} <div class="toolbar"> <div class="pills" role="radiogroup" aria-label="which changes"> {#each scopes as item (item.scope)} <button class="pill" role="radio" aria-checked={scope === item.scope} class:on={scope === item.scope} onclick={() => setScope(item.scope)}>{item.label}</button> {/each} </div> <span class="grow"></span> {#if checkout}<span class="quiet-label" title={checkout.checkout.root}>{checkout.checkout.branch ?? checkout.checkout.head ?? ''} · against {checkout.base.label}</span>{/if} {#if files?.length}<span class="quiet-label num">{shown.length - unread} of {shown.length} read</span>{/if} </div> <div class="stream nowheel" bind:this={stream} onscroll={follow}> {#if problem} <p class="quiet error">(。•︿•。) couldn't read the changes: {problem}</p> {:else if !files} <p class="quiet">(・ω・)ノ gathering the changes…</p> {:else if !files.length} <p class="quiet">( ̄ω ̄) nothing changed {scope === 'turn' ? 'in this turn' : `against ${checkout?.base.label ?? 'HEAD'}`}</p> {:else} {#each shown as file (file.path)} <section class="card" class:read={read.has(file.path)} data-file={file.path}> <header> <span class="badge s-{file.status}">{LETTER[file.status]}</span> <span class="fname" title={file.path}>{file.path.split('/').pop()}</span> <span class="fdir">{file.oldPath ? `from ${name(absolute(file.oldPath))}` : name(absolute(file.path)).split('/').slice(0, -1).join('/')}</span> <span class="grow"></span> {#if count(file.path)}<span class="said">{count(file.path)} ✎</span>{/if} <span class="counts"><span class="added">+{file.added}</span> <span class="removed">−{file.removed}</span></span> {#if file.status !== 'deleted'}<button class="small" onclick={() => open(absolute(file.path))}>open ↗</button>{/if} <label class="check"><input type="checkbox" checked={read.has(file.path)} onchange={(event) => (event.currentTarget.checked ? read.add(file.path) : read.delete(file.path))} />read</label> </header> {#if !read.has(file.path)} {#each file.parts as part, index (index)} {#if part.reason} <p class="quiet">no diff to show: {part.reason}</p> {:else if View} <View key={keyOf(file.path, partChange(scope, index))} path={file.path} change={partChange(scope, index)} diff={part.diff} oncomment={oncomment && ((range) => oncomment(file.path, partChange(scope, index), range))} /> {/if} {/each} {/if} </section> {/each} {#if checkout?.truncated}<p class="quiet">more files changed than one read lists</p>{/if} {#if !unread}<p class="quiet">٩(ˊᗜˋ*)و every file read</p>{/if} <p class="keys">j k files · n p changes · v read · o open · [ ] tabs · w wrap</p> {/if} </div> {:else} {@const file = changedFile.get(active)} <div class="toolbar"> <span class="path" title={active}>{name(active)}</span> <span class="grow"></span> {#if active.endsWith('.md')} <div class="seg"> <button class:on={rendered.get(active)} onclick={() => rendered.set(active!, true)}>rendered</button> <button class:on={!rendered.get(active)} onclick={() => rendered.set(active!, false)}>source</button> </div> {/if} {#if file}<button class="small" onclick={() => goto(file.path)}>its diff</button>{/if} </div> <div class="stream nowheel"> {#key active} <FileView {sessionId} path={active} key={keyOf(active, FILE_CHANGE)} rendered={!!rendered.get(active)} changed={addedLines(active)} oncomment={oncomment && ((range) => oncomment(active!, FILE_CHANGE, range))} /> {/key} </div> {/if} </section></div>
<style> .client { container-type: inline-size; position: relative; display: flex; height: 100%; min-height: 0; font-size: calc(13px * var(--canvas-scale)); } .side { display: flex; flex: none; flex-direction: column; width: 250px; min-height: 0; background: var(--rail); } .side-head { padding: 8px; } .side-list { flex: 1; min-height: 0; padding: 0 6px 12px; overflow-y: auto; scrollbar-width: thin; } .main { display: flex; flex: 1; flex-direction: column; min-width: 0; min-height: 0; } .seg { display: inline-flex; gap: 2px; padding: 2px; background: var(--well); border-radius: 8px; } .seg button { padding: 2px 9px; color: var(--muted); font: inherit; font-size: calc(12px * var(--canvas-scale)); background: none; border: 0; border-radius: 6px; cursor: pointer; } .seg button.on { color: var(--text); background: var(--surface); } .row { display: flex; align-items: center; gap: 7px; width: 100%; min-width: 0; padding: 4px 8px; color: var(--text); font: inherit; font-size: calc(12.5px * var(--canvas-scale)); text-align: left; background: none; border: 0; border-radius: 6px; cursor: pointer; } .row:hover { background: var(--hover); } .row.on { background: var(--selected); } .row.read .fname { color: var(--muted); } .fname { flex: 0 1 auto; min-width: 4ch; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .fdir { flex: 1 1 0; min-width: 0; overflow: hidden; color: var(--faint); font-size: calc(12px * var(--canvas-scale)); white-space: nowrap; text-overflow: ellipsis; } .badge { flex: none; padding: 0 5px; color: color-mix(in oklab, var(--text) 80%, var(--accent)); font-size: calc(10.5px * var(--canvas-scale)); font-weight: 600; background: color-mix(in oklab, var(--accent) 12%, transparent); border-radius: 999px; } .badge.s-added, .badge.s-untracked, .row.read .badge { color: color-mix(in oklab, var(--added) 80%, var(--text)); background: color-mix(in oklab, var(--added) 16%, transparent); } .badge.s-deleted { color: color-mix(in oklab, var(--danger) 80%, var(--text)); background: color-mix(in oklab, var(--danger) 14%, transparent); } .counts, .num { font-variant-numeric: tabular-nums; } .counts { flex: none; white-space: nowrap; } .added { color: var(--added); } .removed { color: var(--danger); } .said { flex: none; color: var(--accent); font-size: calc(11.5px * var(--canvas-scale)); } .tabs { display: flex; gap: 2px; padding: 6px 8px 0; overflow-x: auto; background: var(--title-bar); scrollbar-width: none; } .tab { display: flex; align-items: center; gap: 6px; padding: 5px 10px 6px; color: var(--muted); font: inherit; font-size: calc(12.5px * var(--canvas-scale)); white-space: nowrap; background: none; border: 0; border-radius: 8px 8px 0 0; cursor: pointer; } .tab.on { color: var(--text); background: var(--surface); } .tab-name, .tab-close { padding: 0; color: inherit; font: inherit; background: none; border: 0; cursor: pointer; } .tab-close { color: var(--faint); opacity: 0; } .tab:hover .tab-close, .tab.on .tab-close { opacity: 1; } .wrap { flex: none; align-self: center; margin-bottom: 4px; padding: 2px 9px; color: var(--muted); font: inherit; font-size: calc(12px * var(--canvas-scale)); background: none; border: 0; border-radius: 999px; cursor: pointer; } .wrap:hover { color: var(--text); background: var(--hover); } .wrap[aria-pressed='true'] { color: var(--text); background: var(--well); } .rail-toggle { display: none; padding: 2px 8px; color: var(--muted); font: inherit; background: none; border: 0; cursor: pointer; } .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 14px; } .grow { flex: 1; } .pills { display: flex; gap: 4px; } .pill { padding: 2px 11px; color: var(--muted); font: inherit; font-size: calc(12px * var(--canvas-scale)); background: var(--well); border: 0; border-radius: 999px; cursor: pointer; } .pill.on { color: var(--surface); background: var(--text); } .quiet-label { color: var(--muted); font-size: calc(12px * var(--canvas-scale)); white-space: nowrap; } .path { min-width: 0; overflow: hidden; font-weight: 600; white-space: nowrap; text-overflow: ellipsis; } .small { padding: 2px 9px; color: var(--text); font: inherit; font-size: calc(12px * var(--canvas-scale)); background: var(--well); border: 0; border-radius: 7px; cursor: pointer; } .stream { position: relative; flex: 1; min-height: 0; padding: 0 14px 24px; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; } .card + .card { margin-top: 10px; } /* A card off screen skips layout and paint, so a diff mounting as it comes near lays out its own card rather than the whole stream again. */ .card { content-visibility: auto; contain-intrinsic-size: auto 400px; } .card header { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 8px; padding: 9px 2px; background: var(--surface); } .card header .fname { font-weight: 620; } .card.read header { color: var(--muted); } .card :global(.diff + .diff) { margin-top: 10px; } .check { display: inline-flex; flex: none; align-items: center; gap: 5px; color: var(--muted); font-size: calc(12px * var(--canvas-scale)); cursor: pointer; } .check input { margin: 0; accent-color: var(--accent); } .client:focus { outline: none; } .keys { margin: 22px 0 0; color: var(--faint); font-size: calc(11.5px * var(--canvas-scale)); text-align: center; } @media (pointer: coarse) { .keys { display: none; } } .quiet { margin: 18px 0; color: var(--muted); font-size: calc(12.5px * var(--canvas-scale)); text-align: center; } .error { color: var(--danger); } /* Narrow: the rail folds away behind a button and slides over the stream when asked for. */ @container (max-width: 720px) { .side { position: absolute; inset: 0 auto 0 0; z-index: 3; width: min(280px, 80%); box-shadow: var(--shadow-raised); transform: translateX(-104%); transition: transform 220ms cubic-bezier(0.32, 0.72, 0, 1); } .rail-open .side { transform: none; } .rail-toggle { display: inline-block; } .card header .fdir { display: none; } } @media (prefers-reduced-motion: reduce) { .side { transition: none; } }</style>