| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Session · amem</title> | |
| <script> | |
| // the legacy address, /s/<id>, becomes this page's own, so every relative path below holds | |
| { const m = /\/s\/([^/]+)$/.exec(location.pathname); if (m) location.replace(`../session.html?id=${encodeURIComponent(decodeURIComponent(m[1]))}${location.hash}`); } | |
| // the reader's theme and contrast, set before the first paint; storage is a convenience | |
| try { const s = localStorage, r = document.documentElement; if (s.getItem('amem-theme')) r.dataset.theme = s.getItem('amem-theme'); if (s.getItem('amem-contrast')) r.dataset.contrast = s.getItem('amem-contrast'); } catch (_) { /* private window */ } | |
| </script> | |
| <link rel="stylesheet" href="vendor/xterm.min.css"> | |
| <link rel="stylesheet" href="tokens.css"> | |
| <style> | |
| html, body { height: 100%; } | |
| body.am-body { display: flex; flex-direction: column; padding: var(--am-space-4); height: 100dvh; box-sizing: border-box; } | |
| .am-frame { flex: 1; display: flex; flex-direction: column; min-height: 0; } | |
| .am-bar-title { margin: 0; } | |
| .am-split { flex: 1; min-height: 0; } | |
| .well { background: var(--am-term-bg); padding: var(--am-space-2) 0 var(--am-space-2) var(--am-space-3); min-width: 0; min-height: 0; display: flex; } | |
| #term { flex: 1; min-width: 0; min-height: 0; } | |
| .am-pane { min-height: 0; } | |
| .am-pane-body { min-height: 0; } | |
| .am-pane-head { min-height: 2.25rem; } | |
| .am-recent { max-height: 9.5rem; overflow: auto; } | |
| .am-recent b { font-weight: var(--am-weight-strong); color: var(--am-muted); } | |
| .am-prose { overflow-wrap: anywhere; } | |
| .am-prose img { max-width: 100%; } | |
| .am-prose .mermaid { background: var(--am-surface); border: var(--am-line) solid var(--am-rule-soft); border-radius: var(--am-radius-sm); padding: var(--am-space-2); overflow-x: auto; } | |
| .am-prose .mermaid svg { max-width: 100%; height: auto; } | |
| .notice { padding: var(--am-space-3) var(--am-space-4); border-bottom: var(--am-line) solid var(--am-rule); background: var(--am-surface); } | |
| .am-tabs { display: none; } | |
| .am-strip-v .na { font-weight: var(--am-weight-regular); color: var(--am-muted); } | |
| @media (max-width: 720px) { | |
| body.am-body { padding: 0; } | |
| .am-frame { border-radius: 0; border-width: 0; } | |
| .am-tabs { display: flex; } | |
| .am-split { display: flex; flex-direction: column; } | |
| .am-split > * { flex: 1; } | |
| .am-pane { border-top: 0; } | |
| .am-frame[data-tab="terminal"] .am-pane, .am-frame[data-tab="record"] .well { display: none; } | |
| .am-bar-facts { order: 3; flex-basis: 100%; } | |
| } | |
| </style> | |
| </head> | |
| <body class="am-body"> | |
| <svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><defs> | |
| <symbol id="i-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 3.5H3.5v17h4M16.5 3.5h4v17h-4"/><path d="M8 8.5h8M8 12h8M8 15.5h5"/></symbol> | |
| <symbol id="i-running" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.2"/><circle cx="8" cy="8" r="3" fill="currentColor" stroke="none"/></symbol> | |
| <symbol id="i-connecting" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.2" stroke-dasharray="2.7 2.2"/><circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none"/></symbol> | |
| <symbol id="i-done" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.2"/><path d="M5.3 8.2l1.9 1.9 3.6-3.9"/></symbol> | |
| <symbol id="i-ok" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 8.6l3.3 3.2L13.2 4.6"/></symbol> | |
| <symbol id="i-fail" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/></symbol> | |
| <symbol id="i-warn" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2l6.3 11.2H1.7Z"/><path d="M8 6.6v3M8 11.6v.05"/></symbol> | |
| <symbol id="i-absent" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2" stroke-dasharray="0.1 2.6"/></symbol> | |
| <symbol id="i-refused" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M3.7 12.3l8.6-8.6"/></symbol> | |
| <symbol id="i-ceiling" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 2.8h11"/><path d="M8 13.6V5.8M5.2 8.5L8 5.7l2.8 2.8"/></symbol> | |
| <symbol id="i-admit" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 13.8V2.4h7.2v11.4"/><path d="M1.6 8.1h6.6M6 5.9l2.2 2.2L6 10.3"/></symbol> | |
| <symbol id="i-handoff" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 9.6h3.2l1 1.7h3.6l1-1.7H14v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z"/><path d="M8 2v5.6M5.7 5.4L8 7.7l2.3-2.3"/></symbol> | |
| <symbol id="i-lease" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.2"/><path d="M8 4.6V8l2.4 1.5"/></symbol> | |
| <symbol id="i-queued" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 4.6h10M3 8h10M3 11.4h6"/></symbol> | |
| <symbol id="i-invalid" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M5 8h6"/></symbol> | |
| <symbol id="i-superseded" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.4 8h8.4M8 5l3 3-3 3"/><path d="M13.6 3.2v9.6"/></symbol> | |
| <symbol id="i-demoted" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2v8.2M4.9 7.4L8 10.5l3.1-3.1"/><path d="M3 13.6h10"/></symbol> | |
| <symbol id="i-erased" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.4" y="2.4" width="11.2" height="11.2" rx="1.6" stroke-dasharray="2.2 2.2"/></symbol> | |
| <symbol id="i-copy" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="5.6" y="5.6" width="8" height="8" rx="1.4"/><path d="M10.4 5.6V3.8a1.3 1.3 0 0 0-1.3-1.3H3.8a1.3 1.3 0 0 0-1.3 1.3v5.3a1.3 1.3 0 0 0 1.3 1.3h1.8"/></symbol> | |
| <symbol id="i-terminal" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.8" y="2.6" width="12.4" height="10.8" rx="1.6"/><path d="M4.6 6.2l2 1.8-2 1.8M8.4 10h3"/></symbol> | |
| <symbol id="i-search" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="7" cy="7" r="4.4"/><path d="M10.3 10.3l3.5 3.5"/></symbol> | |
| <symbol id="i-entry" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.8" y="1.8" width="10.4" height="12.4" rx="1.4"/><path d="M5.4 5.4h5.2M5.4 8h5.2M5.4 10.6h3"/></symbol> | |
| <symbol id="i-start" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M4.6 2.9v10.2L12.8 8Z"/></symbol> | |
| <symbol id="i-label-0" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="none"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="none"/></symbol> | |
| <symbol id="i-label-1" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="currentColor"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="none"/></symbol> | |
| <symbol id="i-label-2" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="currentColor"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="currentColor"/></symbol> | |
| <symbol id="i-trust-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6"/></symbol> | |
| <symbol id="i-trust-1" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6"/><path d="M8 2.4a5.6 5.6 0 0 0 0 11.2Z" fill="currentColor" stroke="none"/></symbol> | |
| <symbol id="i-trust-2" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6" fill="currentColor"/></symbol> | |
| <symbol id="i-pair" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="4.8" cy="8" r="2.8"/><path d="M7.6 8h6.6M11.2 8v2.4M13.6 8v1.8"/></symbol> | |
| <symbol id="i-busy" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 1.8a6.2 6.2 0 1 0 6.2 6.2"/></symbol> | |
| </defs></svg> | |
| <a class="am-skip" href="#pane">Skip to the reading pane</a> | |
| <div class="am-frame" id="frame" data-tab="terminal"> | |
| <header class="am-bar" id="bar" tabindex="-1"> | |
| <a class="am-bar-home" href="index.html" title="All sessions">amem</a> | |
| <h1 class="am-bar-title" id="title">Session</h1> | |
| <dl class="am-bar-facts" id="facts" aria-label="Started with"></dl> | |
| <span id="status" role="status"></span> | |
| </header> | |
| <div class="am-strip" id="strip" role="group" aria-label="What crosses the line"> | |
| <div class="am-strip-cell"><span class="am-strip-k">Destination</span><span class="am-strip-v" id="s-dest"></span></div> | |
| <div class="am-strip-cell am-strip-cell--ceiling"><span class="am-strip-k">Label and ceiling</span><span class="am-strip-v" id="s-label"></span></div> | |
| <div class="am-strip-cell"><span class="am-strip-k">Usage so far</span><span class="am-strip-v" id="s-usage"></span></div> | |
| <div class="am-strip-cell"><span class="am-strip-k">Entries written</span><span class="am-strip-v" id="s-written"></span></div> | |
| <div class="am-strip-cell"><span class="am-strip-k">Handoffs waiting</span><span class="am-strip-v" id="s-handoffs"></span></div> | |
| </div> | |
| <div class="notice" id="notice" hidden></div> | |
| <div class="am-tabs" role="tablist" aria-label="Session"> | |
| <button role="tab" type="button" id="tab-terminal" aria-controls="well" aria-selected="true">Terminal</button> | |
| <button role="tab" type="button" id="tab-record" aria-controls="pane" aria-selected="false" tabindex="-1">Record</button> | |
| </div> | |
| <div class="am-split"> | |
| <div class="well" id="well" role="tabpanel" aria-labelledby="tab-terminal"><div id="term"></div></div> | |
| <aside class="am-pane" id="pane" tabindex="-1" role="tabpanel" aria-labelledby="tab-record"> | |
| <div class="am-pane-head"><svg class="am-glyph" aria-hidden="true"><use href="#i-entry"/></svg><span id="why">The record</span></div> | |
| <article class="am-pane-body am-entry" id="entry"> | |
| <div class="am-entry-type" id="kind"></div> | |
| <h2 class="am-entry-title" id="ptitle">Nothing open yet</h2> | |
| <div class="am-entry-meta" id="emeta" hidden></div> | |
| <div class="am-prose" id="body"><p>An entry the session writes opens here as it lands. A <code>mem-</code> id in the terminal opens here when clicked.</p></div> | |
| </article> | |
| <nav class="am-recent" id="recent" aria-label="Recent entries"></nav> | |
| </aside> | |
| </div> | |
| </div> | |
| <div class="am-vh" id="live" aria-live="polite"></div> | |
| <script src="vendor/xterm.min.js"></script> | |
| <script src="vendor/addon-fit.min.js"></script> | |
| <script src="vendor/marked.min.js"></script> | |
| <script src="vendor/mermaid.min.js"></script> | |
| <script src="vendor/purify.min.js"></script> | |
| <script src="amem.js"></script> | |
| <script> | |
| // an entry's body is anyone's text, an agent's or a bundle's: Markdown, with no styles, | |
| // no forms and no remote images, so it can neither restyle the page around it nor call | |
| // out from it | |
| const SANITIZE = { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select', 'option'], FORBID_ATTR: ['style'] }; | |
| DOMPurify.addHook('afterSanitizeAttributes', (n) => { | |
| if (n.tagName === 'IMG' && !/^data:image\//i.test(n.getAttribute('src') || '')) n.removeAttribute('src'); | |
| }); | |
| // ---- small DOM helpers: what the API says is only ever set as text ---- | |
| const $ = (id) => document.getElementById(id); | |
| const NS = 'http://www.w3.org/2000/svg'; | |
| function el(tag, cls, ...kids) { | |
| const e = document.createElement(tag); | |
| if (cls) e.className = cls; | |
| for (const k of kids) if (k !== null && k !== undefined && k !== false) e.append(k); | |
| return e; | |
| } | |
| function glyph(name, label) { | |
| const s = document.createElementNS(NS, 'svg'); | |
| s.setAttribute('class', 'am-glyph'); | |
| if (label) { s.setAttribute('role', 'img'); s.setAttribute('aria-label', label); } else s.setAttribute('aria-hidden', 'true'); | |
| const u = document.createElementNS(NS, 'use'); | |
| u.setAttribute('href', `#i-${name}`); | |
| s.append(u); | |
| return s; | |
| } | |
| const state = (tone, name, label) => el('span', `am-state am-state--${tone}`, glyph(name, label)); | |
| function chip(tone, name, word, count, extra) { | |
| const c = el('span', `am-chip am-chip--${tone}${extra ? ` ${extra}` : ''}`, glyph(name)); | |
| if (count !== undefined) c.append(el('span', 'am-chip-count', String(count))); | |
| c.append(el('span', 'am-chip-word', word)); | |
| c.title = count !== undefined ? `${count} ${word}` : word; | |
| return c; | |
| } | |
| const sub = (t) => el('span', 'am-sub', t); | |
| const na = (t = 'not reported') => el('span', 'na', t); | |
| const LABELS = ['public', 'internal', 'restricted']; | |
| const TIERS = ['raw', 'curated', 'verified']; | |
| const fact = (kind, order, word) => el('span', 'am-fact', glyph(`${kind}-${Math.max(0, order.indexOf(word))}`), word); | |
| const abbrev = (n) => (n >= 10000 ? `${(n / 1000).toFixed(1)}k` : String(n)); | |
| // the session is ?id= in the address; the legacy /s/<id> was turned into it above | |
| const id = new URLSearchParams(location.search).get('id') || ''; | |
| // ---- the terminal: its theme is the tokens, read again whenever the theme changes ---- | |
| const root = document.documentElement; | |
| const css = () => getComputedStyle(root); | |
| function termTheme() { | |
| const c = css(); | |
| const t = (n) => c.getPropertyValue(`--am-term-${n}`).trim(); | |
| const names = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']; | |
| return { | |
| background: t('bg'), foreground: t('fg'), cursor: t('cursor'), cursorAccent: t('cursor-accent'), selectionBackground: t('selection'), | |
| ...Object.fromEntries(names.map((n) => [n, t(n)])), | |
| ...Object.fromEntries(names.map((n) => [`bright${n[0].toUpperCase()}${n.slice(1)}`, t(`bright-${n}`)])), | |
| }; | |
| } | |
| const still = matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| const rem = parseFloat(css().fontSize) || 16; | |
| const term = new Terminal({ | |
| scrollback: 50000, | |
| fontSize: Math.round(rem * (parseFloat(css().getPropertyValue('--am-text-term')) || 0.875)), | |
| fontFamily: css().getPropertyValue('--am-font-mono').trim(), | |
| lineHeight: Number(css().getPropertyValue('--am-leading-term')) || 1.2, | |
| theme: termTheme(), | |
| minimumContrastRatio: 4.5, | |
| cursorBlink: !still, | |
| cursorStyle: 'block', | |
| screenReaderMode: false, | |
| allowProposedApi: true, | |
| }); | |
| const fit = new FitAddon.FitAddon(); | |
| term.loadAddon(fit); | |
| term.open($('term')); | |
| fit.fit(); | |
| term.focus(); | |
| function mermaidInit() { | |
| const c = css(); | |
| const v = (n) => c.getPropertyValue(`--am-${n}`).trim(); | |
| mermaid.initialize({ | |
| startOnLoad: false, theme: 'base', securityLevel: 'strict', | |
| themeVariables: { | |
| primaryColor: v('surface'), primaryBorderColor: v('edge'), primaryTextColor: v('ink'), lineColor: v('muted'), | |
| secondaryColor: v('plate'), tertiaryColor: v('paper'), textColor: v('ink'), fontFamily: v('font-sans'), | |
| }, | |
| }); | |
| } | |
| mermaidInit(); | |
| function retheme() { term.options.theme = termTheme(); mermaidInit(); } | |
| for (const q of ['(prefers-color-scheme: dark)', '(prefers-contrast: more)']) matchMedia(q).addEventListener('change', retheme); | |
| new MutationObserver(retheme).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-contrast'] }); | |
| // ---- the bar: what the session was started with, then what the launcher says ---- | |
| let info = null; | |
| let status = {}; | |
| function renderFacts() { | |
| const l = (info && info.launch) || {}; | |
| const facts = [ | |
| ['Harness', status.harness || l.harness], | |
| ['Model', status.model || l.model], | |
| ['Profile', l.profile], | |
| ].filter(([, v]) => v); | |
| $('facts').replaceChildren(...facts.map(([k, v]) => el('div', null, el('dt', null, k), el('dd', null, v)))); | |
| } | |
| function setState(kind, code) { | |
| const s = $('status'); | |
| if (kind === 'connecting' || kind === 'reconnecting') s.replaceChildren(chip('live', 'connecting', kind, undefined, 'am-chip--provisional')); | |
| else if (kind === 'running') s.replaceChildren(chip('live', 'running', 'running')); | |
| else if (kind === 'exited' && code === 0) s.replaceChildren(el('span', 'am-bar-state', state('ok', 'done'), 'exited 0')); | |
| else if (kind === 'exited') s.replaceChildren(chip('fail', 'fail', code === null || code === undefined ? 'exited' : `exited ${code}`)); | |
| else if (kind === 'unpaired') s.replaceChildren(el('span', 'am-bar-state', state('await', 'pair'), 'not paired')); | |
| else s.replaceChildren(el('span', 'am-bar-state', state('fail', 'fail'), kind)); | |
| } | |
| function notice(tone, glyphName, title, text, action) { | |
| const n = $('notice'); | |
| const p = el('p', null, el('b', null, title), ' ', text); | |
| const box = el('div', `am-alert am-alert--${tone}`, glyph(glyphName), p); | |
| box.setAttribute('role', tone === 'fail' ? 'alert' : 'status'); | |
| if (action) box.append(el('div', 'am-alert-act', action)); | |
| n.replaceChildren(box); | |
| n.hidden = false; | |
| } | |
| // ---- the strip: what crosses the line, from the file the launcher writes ---- | |
| function renderStrip() { | |
| const s = status; | |
| const l = (info && info.launch) || {}; | |
| const known = !!s.destination; | |
| const dest = $('s-dest'); | |
| if (known) { | |
| dest.replaceChildren(s.destination, sub(s.auth || '')); | |
| if (s.metered === false) dest.append(sub('pinned, not metered')); | |
| } else dest.replaceChildren(l.provider || l.auth || na()); | |
| const label = $('s-label'); | |
| if (known && s.label && s.ceiling) { | |
| label.replaceChildren(fact('label', LABELS, s.label), sub(`of ${s.ceiling}`)); | |
| label.title = s.ceiling_of ? `the ceiling grants.toml gives ${s.ceiling_of}` : ''; | |
| if (LABELS.indexOf(s.label) >= LABELS.indexOf(s.ceiling)) label.append(chip('warn', 'ceiling', 'at ceiling')); | |
| } else label.replaceChildren(na()); | |
| const usage = $('s-usage'); | |
| const u = s.usage; | |
| if (known && s.metered !== false && u) { | |
| usage.replaceChildren(`${abbrev(u.input)} in, ${abbrev(u.output)} out`, sub(`${u.calls} call${u.calls === 1 ? '' : 's'}`)); | |
| usage.title = `${u.input} tokens in, ${u.output} out, ${u.calls} calls`; | |
| } else usage.replaceChildren(na(known ? 'not reported: not metered' : 'not reported')); | |
| $('s-written').replaceChildren(known ? String(s.written ?? 0) : na()); | |
| const h = $('s-handoffs'); | |
| if (known && s.handoffs > 0) h.replaceChildren(chip('await', 'handoff', 'to read', s.handoffs)); | |
| else h.replaceChildren(known ? '0' : na()); | |
| } | |
| let statusTimer = null; | |
| async function pollStatus() { | |
| const r = await amem.fetch(`/api/sessions/${encodeURIComponent(id)}/status`).catch(() => null); | |
| if (r && r.ok) { | |
| status = await r.json(); | |
| renderStrip(); | |
| renderFacts(); | |
| } | |
| } | |
| // ---- the socket ---- | |
| let ws = null; | |
| let exited = false; | |
| let tries = 0; | |
| function connect() { | |
| setState(tries ? 'reconnecting' : 'connecting'); | |
| ws = amem.socket(`/ws/${encodeURIComponent(id)}`); | |
| ws.binaryType = 'arraybuffer'; | |
| ws.onopen = () => { | |
| tries = 0; | |
| setState('running'); | |
| $('notice').hidden = true; | |
| term.reset(); | |
| ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); | |
| }; | |
| ws.onmessage = (e) => { | |
| if (typeof e.data === 'string') { | |
| const m = JSON.parse(e.data); | |
| if (m.type === 'exit') { | |
| exited = true; | |
| setState('exited', m.code); | |
| clearInterval(statusTimer); | |
| pollStatus(); | |
| } | |
| return; | |
| } | |
| term.write(new Uint8Array(e.data)); | |
| }; | |
| ws.onclose = async () => { | |
| if (exited) return; | |
| // a refused upgrade looks like any closed socket; ask whether the page has a key | |
| const r = await amem.fetch(`/api/sessions/${encodeURIComponent(id)}`).catch(() => null); | |
| if (r && r.status === 401) { unpaired(); return; } | |
| if (r && r.status === 404) { missing(); return; } | |
| tries += 1; | |
| if (tries > 30) { | |
| setState('offline'); | |
| const again = el('button', 'am-btn am-btn--secondary', 'Try again'); | |
| again.type = 'button'; | |
| again.addEventListener('click', () => { tries = 0; connect(); }); | |
| notice('fail', 'fail', 'Cannot reach amem on this host.', 'The page tried for 30 seconds. The terminal holds what the session printed before the connection went; nothing has been lost from the session itself.', again); | |
| return; | |
| } | |
| setState('reconnecting'); | |
| setTimeout(connect, 1000); | |
| }; | |
| } | |
| term.onData((d) => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data: d })); }); | |
| term.onResize(({ cols, rows }) => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols, rows })); }); | |
| window.addEventListener('resize', () => fit.fit()); | |
| // what a page says when it has no key; a hosted copy also says where amem has to run | |
| const unpairedWhy = () => { const w = amem.unpaired(); return amem.hosted && !/own machine/.test(w) ? `${w} This copy of the pages runs in your browser and talks to amem on your own machine: run amem there, and it opens this page paired.` : w; }; | |
| function unpaired() { | |
| setState('unpaired'); | |
| const cmd = el('div', 'am-cmd', el('code', null, el('span', 'p', '$ '), 'amem')); | |
| notice('await', 'pair', 'Not paired.', unpairedWhy(), cmd); | |
| clearInterval(statusTimer); | |
| } | |
| function missing() { | |
| setState('no such session'); | |
| const back = el('a', 'am-btn am-btn--secondary', 'All sessions'); | |
| back.href = 'index.html'; | |
| notice('fail', 'fail', 'No such session.', id ? 'This server has no session with that id. Sessions end with the server that started them.' : 'This address names no session.', back); | |
| clearInterval(statusTimer); | |
| } | |
| // ---- mem- ids in the terminal open in the pane ---- | |
| const ID = /mem-[a-z0-9]+-[a-z0-9]+/g; | |
| term.registerLinkProvider({ | |
| provideLinks(y, cb) { | |
| const line = term.buffer.active.getLine(y - 1); | |
| if (!line) return cb(undefined); | |
| const text = line.translateToString(true); | |
| const links = []; | |
| for (const m of text.matchAll(ID)) { | |
| links.push({ range: { start: { x: m.index + 1, y }, end: { x: m.index + m[0].length, y } }, text: m[0], activate: () => open(m[0], 'Opened from the terminal') }); | |
| } | |
| cb(links.length ? links : undefined); | |
| }, | |
| }); | |
| // ---- the reading pane ---- | |
| let openId = null; | |
| let unseen = 0; | |
| async function open(entryId, why) { | |
| const r = await amem.fetch(`/api/entry/${encodeURIComponent(entryId)}`); | |
| if (!r.ok) return; | |
| const e = await r.json(); | |
| openId = e.id; | |
| $('why').textContent = why; | |
| $('kind').textContent = e.type; | |
| $('ptitle').textContent = e.title; | |
| const meta = $('emeta'); | |
| meta.replaceChildren(fact('label', LABELS, e.classification), fact('trust', TIERS, e.trust_tier), el('span', 'am-id', e.id), e.author ? el('span', null, e.author) : null); | |
| meta.hidden = false; | |
| const body = $('body'); | |
| // an entry is somebody's Markdown: rendered, then stripped of scripts and handlers | |
| body.innerHTML = DOMPurify.sanitize(marked.parse(e.body), SANITIZE); | |
| const blocks = body.querySelectorAll('pre code.language-mermaid'); | |
| for (const b of blocks) { const d = el('div', 'mermaid'); d.textContent = b.textContent; b.parentElement.replaceWith(d); } | |
| if (blocks.length) { try { await mermaid.run({ nodes: body.querySelectorAll('.mermaid') }); } catch (err) { console.warn(err); } } | |
| for (const a of $('recent').querySelectorAll('a')) { | |
| const on = a.dataset.id === openId; | |
| a.classList.toggle('is-open', on); | |
| if (on) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current'); | |
| } | |
| if (why !== 'Opened from the recent list') $('live').textContent = `${why}: ${e.title}`; | |
| if ($('frame').dataset.tab === 'terminal' && narrow.matches) { unseen += 1; recordTab(); } | |
| } | |
| const seen = new Set(); | |
| let first = true; | |
| async function poll() { | |
| try { | |
| const r = await amem.fetch('/api/entries'); | |
| if (!r.ok) return; | |
| const list = await r.json(); | |
| // titles are anyone's text, an agent's or a bundle's: set as text, never as markup | |
| $('recent').replaceChildren(...list.slice(0, 8).map((e) => { | |
| const a = el('a', e.id === openId ? 'is-open' : null, el('b', null, `${e.type}: `), e.title); | |
| a.href = `#${e.id}`; | |
| a.dataset.id = e.id; | |
| if (e.id === openId) a.setAttribute('aria-current', 'true'); | |
| a.addEventListener('click', (ev) => { ev.preventDefault(); open(e.id, 'Opened from the recent list'); }); | |
| return a; | |
| })); | |
| const fresh = list.filter((e) => !seen.has(e.id)); | |
| list.forEach((e) => seen.add(e.id)); | |
| if (!first && fresh.length) open(fresh[0].id, 'Landed during this session'); | |
| first = false; | |
| } catch (e) { /* the server may be busy; try again */ } | |
| } | |
| // ---- tabs, below 720px: the terminal and the record ---- | |
| const narrow = matchMedia('(max-width: 720px)'); | |
| function recordTab() { | |
| const t = $('tab-record'); | |
| t.replaceChildren('Record'); | |
| if (unseen) t.append(chip('ink', 'entry', 'new', unseen)); | |
| } | |
| function selectTab(which) { | |
| $('frame').dataset.tab = which; | |
| for (const [name, b] of [['terminal', $('tab-terminal')], ['record', $('tab-record')]]) { | |
| b.setAttribute('aria-selected', String(name === which)); | |
| b.tabIndex = name === which ? 0 : -1; | |
| } | |
| if (which === 'record') { unseen = 0; recordTab(); } else requestAnimationFrame(() => { fit.fit(); term.focus(); }); | |
| } | |
| $('tab-terminal').addEventListener('click', () => selectTab('terminal')); | |
| $('tab-record').addEventListener('click', () => selectTab('record')); | |
| for (const b of document.querySelectorAll('[role=tab]')) { | |
| b.addEventListener('keydown', (e) => { | |
| if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; | |
| const next = $('frame').dataset.tab === 'terminal' ? 'record' : 'terminal'; | |
| selectTab(next); | |
| $(`tab-${next}`).focus(); | |
| }); | |
| } | |
| narrow.addEventListener('change', () => requestAnimationFrame(() => fit.fit())); | |
| // ---- F6 moves between the bar, the terminal and the pane: the terminal takes Tab ---- | |
| term.attachCustomKeyEventHandler((e) => e.key !== 'F6'); | |
| window.addEventListener('keydown', (e) => { | |
| if (e.key !== 'F6') return; | |
| e.preventDefault(); | |
| const stops = [$('bar'), $('term').querySelector('textarea'), $('pane')]; | |
| const at = stops.findIndex((s) => s && s.contains(document.activeElement)); | |
| const next = stops[(at + (e.shiftKey ? stops.length - 1 : 1)) % stops.length]; | |
| if (next === $('pane') && narrow.matches) selectTab('record'); | |
| if (next !== $('pane') && narrow.matches && $('frame').dataset.tab === 'record') selectTab('terminal'); | |
| next.focus(); | |
| }); | |
| // ---- start ---- | |
| async function start() { | |
| await amem.ready; | |
| renderStrip(); | |
| if (!id) { missing(); return; } | |
| if (!amem.paired()) { unpaired(); return; } | |
| const r = await amem.fetch(`/api/sessions/${encodeURIComponent(id)}`).catch(() => null); | |
| if (r && r.status === 401) { unpaired(); return; } | |
| if (!r || r.status === 404) { missing(); return; } | |
| if (r.ok) { | |
| info = await r.json(); | |
| $('title').textContent = info.title; | |
| document.title = `${info.title} · amem`; | |
| renderFacts(); | |
| renderStrip(); | |
| } | |
| connect(); | |
| pollStatus(); | |
| statusTimer = setInterval(pollStatus, 2000); | |
| poll(); | |
| setInterval(poll, 2000); | |
| } | |
| start(); | |
| </script> | |
| </body> | |
| </html> |