@leftoff/cli 0.0.0-stage → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/CHANGELOG.md +564 -0
- package/LICENSE.md +9 -0
- package/README.md +28 -2
- package/assets/sample-transcript.jsonl +12 -0
- package/bin/checkpoints.js +7 -0
- package/bin/leftoff.js +585 -0
- package/hooks/README.md +22 -0
- package/hooks/settings-hooks.example.json +82 -0
- package/mod/.claude-plugin/plugin.json +15 -0
- package/mod/hooks/core.js +1397 -0
- package/mod/hooks/hooks.json +4 -0
- package/mod/hooks/register.js +1006 -0
- package/package.json +45 -4
- package/templates/agent-feed-worker/README.md +45 -0
- package/templates/agent-feed-worker/src/index.js +69 -0
- package/templates/agent-feed-worker/wrangler.toml +20 -0
- package/ui/app.css +1282 -0
- package/ui/app.js +2979 -0
- package/ui/apple-touch-icon.png +0 -0
- package/ui/changelog-md.js +64 -0
- package/ui/favicon-32.png +0 -0
- package/ui/favicon-badge-32.png +0 -0
- package/ui/favicon-badge.svg +1 -0
- package/ui/favicon.ico +0 -0
- package/ui/favicon.svg +1 -0
- package/ui/go.js +68 -0
- package/ui/icon-192.png +0 -0
- package/ui/icon-512.png +0 -0
- package/ui/icon-maskable-512.png +0 -0
- package/ui/icons.js +41 -0
- package/ui/index.html +55 -0
- package/ui/manifest.webmanifest +37 -0
- package/ui/needs.js +197 -0
- package/ui/offline.css +11 -0
- package/ui/offline.html +24 -0
- package/ui/pair.html +31 -0
- package/ui/picker.js +294 -0
- package/ui/relay-client.js +187 -0
- package/ui/relay-crypto.js +155 -0
- package/ui/relay-pair.js +48 -0
- package/ui/repos.js +167 -0
- package/ui/settings-nav.js +23 -0
- package/ui/summary-examples.js +66 -0
- package/ui/sw.js +34 -0
- package/ui/theme.js +41 -0
- package/ui/vendor/LUCIDE-LICENSE.txt +45 -0
|
Binary file
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The small markdown the What's new page and the board's text fields use
|
|
2
|
+
// (0.6.2): **bold**, `code`, [text](url) links, bare https links and nested
|
|
3
|
+
// "- " bullets. Pure functions, so the unit tests read this file too
|
|
4
|
+
// (test/unit/changelog-md.test.mjs).
|
|
5
|
+
//
|
|
6
|
+
// Links: CHANGELOG.md ships inside the npm package and the repo is private,
|
|
7
|
+
// so only an absolute https link becomes a real link (new tab). A relative
|
|
8
|
+
// link (docs/relay.md, README.md#…) or any other scheme shows as its text.
|
|
9
|
+
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
10
|
+
|
|
11
|
+
const httpsUrl = (u) => /^https:\/\/[^\s"'<>]+$/i.test(String(u || '').trim())
|
|
12
|
+
const anchor = (href, inner) => '<a href="' + esc(href) + '" target="_blank" rel="noopener">' + inner + '</a>'
|
|
13
|
+
|
|
14
|
+
// One line of inline markdown -> HTML. Everything that isn't markup is escaped.
|
|
15
|
+
export function mdInline(s, { links = true } = {}) {
|
|
16
|
+
const held = []
|
|
17
|
+
const hold = (html) => '\u0000' + (held.push(html) - 1) + '\u0000'
|
|
18
|
+
const back = (t) => t.replace(/\u0000(\d+)\u0000/g, (_, i) => held[Number(i)])
|
|
19
|
+
const marks = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
|
20
|
+
let t = String(s == null ? '' : s)
|
|
21
|
+
// `code` first, so nothing inside it is read as markup.
|
|
22
|
+
t = t.replace(/`([^`]+)`/g, (_, c) => hold('<code>' + esc(c) + '</code>'))
|
|
23
|
+
// [text](url "title"): a real link only for https; otherwise just the text.
|
|
24
|
+
t = t.replace(/\[([^\]]+)\]\(\s*<?([^)\s>]*)>?(?:\s+"[^"]*")?\s*\)/g, (_, text, url) => {
|
|
25
|
+
const inner = back(marks(text))
|
|
26
|
+
return hold(links && httpsUrl(url) ? anchor(url, inner) : inner)
|
|
27
|
+
})
|
|
28
|
+
t = marks(t)
|
|
29
|
+
if (links) t = t.replace(/(^|[\s(])(https?:\/\/[^\s<)\u0000]+[^\s<).,\u0000])/g, (_, pre, url) => pre + anchor(url.replace(/&/g, '&'), url))
|
|
30
|
+
return back(t)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// "- " / "* " bullets with indentation -> [{ text, children }]. A line indented
|
|
34
|
+
// under an item without a bullet continues it (a wrapped line).
|
|
35
|
+
export function parseList(md) {
|
|
36
|
+
const root = { indent: -1, children: [] }
|
|
37
|
+
const stack = [root]
|
|
38
|
+
let last = null
|
|
39
|
+
for (const line of String(md || '').split(/\r?\n/)) {
|
|
40
|
+
const m = line.match(/^(\s*)[-*+]\s+(.*)$/)
|
|
41
|
+
if (m) {
|
|
42
|
+
const indent = m[1].replace(/\t/g, ' ').length
|
|
43
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop()
|
|
44
|
+
last = { indent, text: m[2].trim(), children: [] }
|
|
45
|
+
stack[stack.length - 1].children.push(last)
|
|
46
|
+
stack.push(last)
|
|
47
|
+
} else if (last && /^\s+\S/.test(line)) last.text += ' ' + line.trim()
|
|
48
|
+
else if (!line.trim()) continue
|
|
49
|
+
else { last = null; stack.length = 1 }
|
|
50
|
+
}
|
|
51
|
+
const strip = (n) => n.map(({ text, children }) => ({ text, children: strip(children) }))
|
|
52
|
+
return strip(root.children)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderList(items) {
|
|
56
|
+
if (!items || !items.length) return ''
|
|
57
|
+
return '<ul>' + items.map((it) => '<li>' + mdInline(it.text) + renderList(it.children) + '</li>').join('') + '</ul>'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One changelog section ({ title, items, list? }) -> its HTML.
|
|
61
|
+
export function renderSection(s) {
|
|
62
|
+
const list = s.list && s.list.length ? s.list : (s.items || []).map((text) => ({ text, children: [] }))
|
|
63
|
+
return '<h3 class="wn-sec s-' + esc(String(s.title || '').toLowerCase()) + '">' + esc(s.title) + '</h3>' + renderList(list)
|
|
64
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"><rect width="32" height="32" rx="7" fill="#FBBF24"/><path d="M8 2 H24 Q30 2 30 8 V18 Q30 24 24 24 H14 L6 30 V24 Q2 23 2 18 V8 Q2 2 8 2 Z" fill="#18181B"/><rect x="14" y="6" width="4" height="10" rx="1" fill="#FDE047"/><rect x="14" y="18" width="4" height="4" rx="1" fill="#FDE047"/><circle cx="25" cy="7" r="6.5" fill="#EF4444" stroke="#FFFFFF" stroke-width="2"/></svg>
|
package/ui/favicon.ico
ADDED
|
Binary file
|
package/ui/favicon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"><rect width="32" height="32" rx="7" fill="#FBBF24"/><path d="M8 2 H24 Q30 2 30 8 V18 Q30 24 24 24 H14 L6 30 V24 Q2 23 2 18 V8 Q2 2 8 2 Z" fill="#18181B"/><rect x="14" y="6" width="4" height="10" rx="1" fill="#FDE047"/><rect x="14" y="18" width="4" height="4" rx="1" fill="#FDE047"/></svg>
|
package/ui/go.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The board's Go button, start to finish (v0.4.1). In v0.4.0 the button just
|
|
2
|
+
// went grey until the daemon answered, then quietly came back, and a slow Go
|
|
3
|
+
// looked like nothing happened. Now:
|
|
4
|
+
// click "Switching…" on the button at once
|
|
5
|
+
// after 2 s a toast: "Ghostty is taking a moment…"
|
|
6
|
+
// answer a toast with the result and how long it took; the button
|
|
7
|
+
// says "✓ Switched" or "✗ Didn't switch" for a few seconds
|
|
8
|
+
// no answer 25 s the request is dropped and the toast says so
|
|
9
|
+
// The board re-renders cards on every update, so the button's state lives in
|
|
10
|
+
// goState (by session id) and goButton() in app.js draws from it.
|
|
11
|
+
|
|
12
|
+
export const SLOW_NOTE_MS = 2000
|
|
13
|
+
export const GIVE_UP_MS = 25000
|
|
14
|
+
export const SETTLE_MS = 3000
|
|
15
|
+
export const goState = new Map() // session id -> { label, disabled, cls }
|
|
16
|
+
|
|
17
|
+
export function secs(ms) {
|
|
18
|
+
const n = Number(ms) || 0
|
|
19
|
+
return (n < 10000 ? (n / 1000).toFixed(1) : Math.round(n / 1000)) + ' s'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stepsText(steps) {
|
|
23
|
+
return (steps || []).map((s) => s.step + ' ' + secs(s.ms) + (s.result && !['ok', 'true'].includes(s.result) ? ' (' + s.result + ')' : '')).join(', ')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The daemon's answer -> what the person sees.
|
|
27
|
+
export function goOutcome(r, { title, label, ms } = {}) {
|
|
28
|
+
const res = r || {}
|
|
29
|
+
const took = res.elapsedMs != null ? res.elapsedMs : ms
|
|
30
|
+
const name = title ? '“' + title + '”' : 'the session'
|
|
31
|
+
const where = res.label || label || 'its terminal'
|
|
32
|
+
// The breakdown only when it was slow, so a slow Go is something to send us.
|
|
33
|
+
const detail = took != null && took >= 1500 && res.steps && res.steps.length ? ' (' + stepsText(res.steps) + ')' : ''
|
|
34
|
+
if (res.ok) return { ok: true, label: '✓ Switched', cls: 'done', toast: 'Switched to ' + name + ' in ' + where + (took != null ? ' · ' + secs(took) : '') + detail, toastMs: detail ? 8000 : 4000 }
|
|
35
|
+
const msg = res.message || 'Couldn’t switch'
|
|
36
|
+
const slowNote = took != null && took >= 1500 && res.code !== 'timeout' ? ' (' + secs(took) + (detail ? ': ' + stepsText(res.steps) : '') + ')' : detail
|
|
37
|
+
return { ok: false, label: '✗ Didn’t switch', cls: 'failed', toast: msg + slowNote, toastMs: 10000 }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function paintGo(btn, st) {
|
|
41
|
+
if (!btn) return
|
|
42
|
+
if (!btn.dataset.idle) btn.dataset.idle = btn.textContent
|
|
43
|
+
btn.textContent = st ? st.label : btn.dataset.idle
|
|
44
|
+
btn.disabled = !!(st && st.disabled)
|
|
45
|
+
btn.classList.remove('busy', 'done', 'failed')
|
|
46
|
+
if (st && st.cls) btn.classList.add(st.cls)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// post() -> the daemon's JSON (or throws). getBtn() -> the button as it is
|
|
50
|
+
// on the page now (re-renders replace it). Returns the outcome.
|
|
51
|
+
export async function goWithFeedback({ id, post, toast, getBtn, title, label, now = () => Date.now(), setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
52
|
+
const set = (st) => { if (st) goState.set(id, st); else goState.delete(id); paintGo(getBtn(), st) }
|
|
53
|
+
if (goState.get(id) && goState.get(id).cls === 'busy') return null // already switching
|
|
54
|
+
set({ label: 'Switching…', disabled: true, cls: 'busy' })
|
|
55
|
+
const t0 = now()
|
|
56
|
+
const slow = setTimer(() => toast((label || 'The terminal') + ' is taking a moment…', GIVE_UP_MS), SLOW_NOTE_MS)
|
|
57
|
+
let r
|
|
58
|
+
try { r = await post() } catch (e) {
|
|
59
|
+
const timedOut = e && (e.name === 'TimeoutError' || e.name === 'AbortError')
|
|
60
|
+
r = { ok: false, code: timedOut ? 'no_answer' : (e && e.code) || 'error', message: timedOut ? 'The Leftoff daemon didn’t answer in ' + secs(GIVE_UP_MS) + '. The tab may still switch.' : (e && e.message) || 'Couldn’t reach the daemon' }
|
|
61
|
+
}
|
|
62
|
+
clearTimer(slow)
|
|
63
|
+
const o = goOutcome(r, { title, label, ms: now() - t0 })
|
|
64
|
+
toast(o.toast, o.toastMs)
|
|
65
|
+
set({ label: o.label, disabled: true, cls: o.cls })
|
|
66
|
+
setTimer(() => set(null), SETTLE_MS)
|
|
67
|
+
return o
|
|
68
|
+
}
|
package/ui/icon-192.png
ADDED
|
Binary file
|
package/ui/icon-512.png
ADDED
|
Binary file
|
|
Binary file
|
package/ui/icons.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A handful of Lucide icons (https://lucide.dev, v1.54.0, ISC license; the
|
|
2
|
+
// notice is in ui/vendor/LUCIDE-LICENSE.txt). Stroke icons in currentColor,
|
|
3
|
+
// so they follow light and dark; always aria-hidden next to a text label.
|
|
4
|
+
// index.html carries the nav's copies inline (no script needed to draw them).
|
|
5
|
+
export const ICONS = {
|
|
6
|
+
"clipboard": "<rect width=\"8\" height=\"4\" x=\"8\" y=\"2\" rx=\"1\" ry=\"1\"/> <path d=\"M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2\"/>",
|
|
7
|
+
"layout-dashboard": "<rect width=\"7\" height=\"9\" x=\"3\" y=\"3\" rx=\"1\"/> <rect width=\"7\" height=\"5\" x=\"14\" y=\"3\" rx=\"1\"/> <rect width=\"7\" height=\"9\" x=\"14\" y=\"12\" rx=\"1\"/> <rect width=\"7\" height=\"5\" x=\"3\" y=\"16\" rx=\"1\"/>",
|
|
8
|
+
"coffee": "<path d=\"M10 2v2\"/> <path d=\"M14 2v2\"/> <path d=\"M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1\"/> <path d=\"M6 2v2\"/>",
|
|
9
|
+
"history": "<path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"/> <path d=\"M3 3v5h5\"/> <path d=\"M12 7v5l4 2\"/>",
|
|
10
|
+
"newspaper": "<path d=\"M15 18h-5\"/> <path d=\"M18 14h-8\"/> <path d=\"M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2\"/> <rect width=\"8\" height=\"4\" x=\"10\" y=\"6\" rx=\"1\"/>",
|
|
11
|
+
"sparkles": "<path d=\"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z\"/> <path d=\"M20 2v4\"/> <path d=\"M22 4h-4\"/> <circle cx=\"4\" cy=\"20\" r=\"2\"/>",
|
|
12
|
+
"settings": "<path d=\"M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915\"/> <circle cx=\"12\" cy=\"12\" r=\"3\"/>",
|
|
13
|
+
"rewind": "<path d=\"M12 6a2 2 0 0 0-3.414-1.414l-6 6a2 2 0 0 0 0 2.828l6 6A2 2 0 0 0 12 18z\"/> <path d=\"M22 6a2 2 0 0 0-3.414-1.414l-6 6a2 2 0 0 0 0 2.828l6 6A2 2 0 0 0 22 18z\"/>",
|
|
14
|
+
"download": "<path d=\"M12 15V3\"/> <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/> <path d=\"m7 10 5 5 5-5\"/>",
|
|
15
|
+
"qr-code": "<rect width=\"5\" height=\"5\" x=\"3\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"16\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"3\" y=\"16\" rx=\"1\"/> <path d=\"M21 16h-3a2 2 0 0 0-2 2v3\"/> <path d=\"M21 21v.01\"/> <path d=\"M12 7v3a2 2 0 0 1-2 2H7\"/> <path d=\"M3 12h.01\"/> <path d=\"M12 3h.01\"/> <path d=\"M12 16v.01\"/> <path d=\"M16 12h1\"/> <path d=\"M21 12v.01\"/> <path d=\"M12 21v-1\"/>",
|
|
16
|
+
"refresh-cw": "<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/> <path d=\"M21 3v5h-5\"/> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/> <path d=\"M8 16H3v5\"/>",
|
|
17
|
+
"check": "<path d=\"M20 6 9 17l-5-5\"/>",
|
|
18
|
+
"folder-sync": "<path d=\"M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v.5\"/> <path d=\"M12 10v4h4\"/> <path d=\"m12 14 1.535-1.605a5 5 0 0 1 8 1.5\"/> <path d=\"M22 22v-4h-4\"/> <path d=\"m22 18-1.535 1.605a5 5 0 0 1-8-1.5\"/>",
|
|
19
|
+
"bell": "<path d=\"M10.268 21a2 2 0 0 0 3.464 0\"/> <path d=\"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326\"/>",
|
|
20
|
+
"x": "<path d=\"M18 6 6 18\"/> <path d=\"m6 6 12 12\"/>",
|
|
21
|
+
"rotate-ccw": "<path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"/> <path d=\"M3 3v5h5\"/>",
|
|
22
|
+
"list-checks": "<path d=\"M13 5h8\"/> <path d=\"M13 12h8\"/> <path d=\"M13 19h8\"/> <path d=\"m3 17 2 2 4-4\"/> <path d=\"m3 7 2 2 4-4\"/>",
|
|
23
|
+
"plus": "<path d=\"M5 12h14\"/> <path d=\"M12 5v14\"/>",
|
|
24
|
+
"play": "<path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/>",
|
|
25
|
+
"sliders-horizontal": "<path d=\"M10 5H3\"/> <path d=\"M12 19H3\"/> <path d=\"M14 3v4\"/> <path d=\"M16 17v4\"/> <path d=\"M21 12h-9\"/> <path d=\"M21 19h-5\"/> <path d=\"M21 5h-7\"/> <path d=\"M8 10v4\"/> <path d=\"M8 12H3\"/>",
|
|
26
|
+
"layers": "<path d=\"M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z\"/> <path d=\"M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12\"/> <path d=\"M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17\"/>",
|
|
27
|
+
"bot": "<path d=\"M12 8V4H8\"/> <rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/> <path d=\"M2 14h2\"/> <path d=\"M20 14h2\"/> <path d=\"M15 13v2\"/> <path d=\"M9 13v2\"/>",
|
|
28
|
+
"smartphone": "<rect width=\"14\" height=\"20\" x=\"5\" y=\"2\" rx=\"2\" ry=\"2\"/> <path d=\"M12 18h.01\"/>",
|
|
29
|
+
"shield": "<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\"/>",
|
|
30
|
+
"info": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M12 16v-4\"/> <path d=\"M12 8h.01\"/>",
|
|
31
|
+
"chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
|
|
32
|
+
"chevron-left": "<path d=\"m15 18-6-6 6-6\"/>",
|
|
33
|
+
"mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/> <path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
|
|
34
|
+
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function icon(name, cls = 'ic') {
|
|
38
|
+
const body = ICONS[name]
|
|
39
|
+
if (!body) return ''
|
|
40
|
+
return '<svg class="' + cls + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + body + '</svg>'
|
|
41
|
+
}
|
package/ui/index.html
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<title>Leftoff</title>
|
|
7
|
+
<link rel="manifest" href="manifest.webmanifest">
|
|
8
|
+
<meta name="theme-color" content="#f5f6f9" media="(prefers-color-scheme: light)">
|
|
9
|
+
<meta name="theme-color" content="#0d1016" media="(prefers-color-scheme: dark)">
|
|
10
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
11
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
12
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
13
|
+
<meta name="application-name" content="Leftoff">
|
|
14
|
+
<link rel="icon" href="favicon.svg" type="image/svg+xml" id="favicon">
|
|
15
|
+
<link rel="icon" href="favicon-32.png" type="image/png" sizes="32x32" id="favicon-png">
|
|
16
|
+
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
|
17
|
+
<meta name="apple-mobile-web-app-title" content="Leftoff">
|
|
18
|
+
<meta name="color-scheme" content="dark light">
|
|
19
|
+
<script src="theme.js"></script>
|
|
20
|
+
<link rel="stylesheet" href="app.css">
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<header class="top">
|
|
24
|
+
<a class="brand" href="#/" id="brand" title="Leftoff">
|
|
25
|
+
<img src="favicon.svg" class="logo" alt="" width="22" height="22">
|
|
26
|
+
<span>Leftoff</span>
|
|
27
|
+
</a>
|
|
28
|
+
<nav class="tabs" aria-label="Sections">
|
|
29
|
+
<a href="#/" data-route="board"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="7" height="9" x="3" y="3" rx="1"/> <rect width="7" height="5" x="14" y="3" rx="1"/> <rect width="7" height="9" x="14" y="12" rx="1"/> <rect width="7" height="5" x="3" y="16" rx="1"/></svg><span class="lbl">Board</span></a>
|
|
30
|
+
<a href="#/waiting" data-route="waiting" id="needsTab"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg><span class="lbl">Needs you</span><span class="lbl-short">Needs</span><span class="navcount" id="needsCount" hidden></span></a>
|
|
31
|
+
<a href="#/lists" data-route="lists" id="listsTab"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M13 5h8"/> <path d="M13 12h8"/> <path d="M13 19h8"/> <path d="m3 17 2 2 4-4"/> <path d="m3 7 2 2 4-4"/></svg><span class="lbl">Lists</span><span class="navcount" id="listsCount" hidden></span></a>
|
|
32
|
+
<a href="#/timeline" data-route="timeline"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span class="lbl">Timeline</span></a>
|
|
33
|
+
<a href="#/digest" data-route="digest"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span class="lbl">Digest</span></a>
|
|
34
|
+
</nav>
|
|
35
|
+
<div class="topright">
|
|
36
|
+
<button type="button" class="searchbtn" id="searchBtn" title="Search (⌘K)" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K /"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><span class="sb-lbl">Search</span><kbd class="sb-kbd" id="searchKbd">⌘K</kbd></button>
|
|
37
|
+
<button type="button" class="health" id="health" title="Status" aria-haspopup="dialog" aria-expanded="false" aria-controls="statusPop"><span class="dot"></span><span class="label">connecting…</span></button>
|
|
38
|
+
<a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" title="What’s new" aria-label="What’s new"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span class="newdot" hidden></span></a>
|
|
39
|
+
<a href="#/settings" data-route="settings" id="setTab" class="iconbtn" title="Settings" aria-label="Settings"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span class="topdot" hidden></span></a>
|
|
40
|
+
</div>
|
|
41
|
+
</header>
|
|
42
|
+
<div class="statuspop" id="statusPop" role="dialog" aria-label="Status" hidden></div>
|
|
43
|
+
<div class="cmdk" id="cmdk" hidden>
|
|
44
|
+
<div class="cmdk-panel" role="dialog" aria-modal="true" aria-label="Search">
|
|
45
|
+
<form class="cmdk-field" id="searchForm" autocomplete="off" role="search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><input id="q" type="search" placeholder="Search sessions, checkpoints, files, settings…" aria-label="Search" role="combobox" aria-expanded="true" aria-controls="cmdkList" aria-autocomplete="list" autocomplete="off" spellcheck="false"><button type="button" class="cmdk-esc" data-cmdk-close aria-label="Close search">esc</button></form>
|
|
46
|
+
<div class="cmdk-list" id="cmdkList" role="listbox" aria-label="Results"></div>
|
|
47
|
+
<div class="cmdk-foot"><span><kbd>↑</kbd><kbd>↓</kbd> to move</span><span><kbd>↵</kbd> to open</span><span><kbd>esc</kbd> to close</span></div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<main id="app"></main>
|
|
51
|
+
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
52
|
+
<script src="relay-client.js" type="module"></script>
|
|
53
|
+
<script src="app.js" type="module"></script>
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Leftoff",
|
|
3
|
+
"short_name": "Leftoff",
|
|
4
|
+
"description": "WTF was I working on? Every Claude Code session and where you left off.",
|
|
5
|
+
"id": "/",
|
|
6
|
+
"start_url": "/",
|
|
7
|
+
"scope": "/",
|
|
8
|
+
"display": "standalone",
|
|
9
|
+
"orientation": "any",
|
|
10
|
+
"background_color": "#0d1016",
|
|
11
|
+
"theme_color": "#0d1016",
|
|
12
|
+
"icons": [
|
|
13
|
+
{
|
|
14
|
+
"src": "/icon-192.png",
|
|
15
|
+
"sizes": "192x192",
|
|
16
|
+
"type": "image/png",
|
|
17
|
+
"purpose": "any"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"src": "/icon-512.png",
|
|
21
|
+
"sizes": "512x512",
|
|
22
|
+
"type": "image/png",
|
|
23
|
+
"purpose": "any"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"src": "/icon-maskable-512.png",
|
|
27
|
+
"sizes": "512x512",
|
|
28
|
+
"type": "image/png",
|
|
29
|
+
"purpose": "maskable"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"src": "/apple-touch-icon.png",
|
|
33
|
+
"sizes": "180x180",
|
|
34
|
+
"type": "image/png"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
package/ui/needs.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Needs you (v0.4.4): the strip at the top of the board, the badge on a card and
|
|
2
|
+
// the panel on a session's page. Pure functions over the daemon's needsYou items
|
|
3
|
+
// (shared with the unit tests); app.js wires the clicks.
|
|
4
|
+
//
|
|
5
|
+
// Kinds: permission (Allow once / Allow for this session / Deny with a note),
|
|
6
|
+
// question (AskUserQuestion's options as buttons, plus Other…), terminal (only
|
|
7
|
+
// the terminal can answer: Go), reply (the turn ended on a question: a reply box
|
|
8
|
+
// when the mod can type it in). Nothing is ever answered without a click.
|
|
9
|
+
|
|
10
|
+
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
11
|
+
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
12
|
+
|
|
13
|
+
export function waitLabel(sec) {
|
|
14
|
+
if (sec == null) return ''
|
|
15
|
+
if (sec < 60) return 'waiting ' + Math.max(1, Math.round(sec)) + 's'
|
|
16
|
+
if (sec < 3600) return 'waiting ' + Math.round(sec / 60) + 'm'
|
|
17
|
+
if (sec < 86400) return 'waiting ' + Math.round(sec / 3600) + 'h'
|
|
18
|
+
return 'waiting ' + Math.round(sec / 86400) + 'd'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The items the strip shows, from /v1/sessions: blocked sessions first (longest
|
|
22
|
+
// wait first), then turns that ended on a question.
|
|
23
|
+
export function needsItems(sessions = []) {
|
|
24
|
+
return sessions.filter((s) => s.needsYou && !s.archived).map((s) => ({ ...s.needsYou, session: s }))
|
|
25
|
+
.sort((a, b) => (b.blocking - a.blocking) || ((b.waitSec || 0) - (a.waitSec || 0)))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// One line for a card or a notification.
|
|
29
|
+
export function needsLine(n) {
|
|
30
|
+
if (!n) return ''
|
|
31
|
+
if (n.kind === 'permission' && n.ask && n.ask.describe) return n.ask.describe.verb + ': ' + n.ask.describe.detail
|
|
32
|
+
if (n.kind === 'question' && n.ask) return ((n.ask.questions || [])[0] || {}).question || 'Claude has a question'
|
|
33
|
+
if (n.kind === 'terminal') return n.message || 'waiting in the terminal'
|
|
34
|
+
if (n.kind === 'agent') return (n.items || []).map((x) => x.text).join(' · ') || n.message || ''
|
|
35
|
+
return n.question || ''
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const KIND_LABEL = { permission: 'Needs your OK', question: 'Question', terminal: 'Waiting in the terminal', reply: 'Asked you', agent: 'Waiting on you' }
|
|
39
|
+
|
|
40
|
+
export function cardBadge(n) {
|
|
41
|
+
if (!n) return ''
|
|
42
|
+
return '<div class="need-badge k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + '" title="' + esc(needsLine(n)) + '"><span class="nb-k">' + esc(KIND_LABEL[n.kind] || 'Needs you') + '</span>' +
|
|
43
|
+
'<span class="nb-t">' + esc(needsLine(n)) + '</span>' + (n.blocking && n.waitSec != null ? '<span class="nb-w">' + esc(waitLabel(n.waitSec).replace('waiting ', '')) + '</span>' : '') + '</div>'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ⌘↵ / Ctrl+↵ in a Needs-you text box presses its button (v0.4.6, app.js
|
|
47
|
+
// wires the key). The hint on the button; opts.kbd picks the platform's label.
|
|
48
|
+
export const KBD_SUBMIT = 'Meta+Enter Control+Enter'
|
|
49
|
+
const kbdHint = (opts) => '<span class="kbd-hint" aria-hidden="true">' + esc(opts.kbd || '⌘↵') + '</span>'
|
|
50
|
+
|
|
51
|
+
// opts: { mayAnswer, goButton(session) -> html, drafts: { [key]: text }, pending: { [key]: label }, compact, kbd }
|
|
52
|
+
export function needPanel(n, opts = {}) {
|
|
53
|
+
const s = n.session || {}
|
|
54
|
+
const drafts = opts.drafts || {}
|
|
55
|
+
const may = opts.mayAnswer !== false
|
|
56
|
+
const askId = n.ask ? n.ask.id : ''
|
|
57
|
+
const key = askId || 'reply:' + s.id
|
|
58
|
+
const busy = opts.pending && opts.pending[key]
|
|
59
|
+
const dis = !may || busy ? ' disabled' : ''
|
|
60
|
+
if (n.kind === 'agent') return agentNeedPanel(n, s)
|
|
61
|
+
const go = opts.goButton ? opts.goButton(s) : ''
|
|
62
|
+
let h = '<article class="need k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '">'
|
|
63
|
+
h += '<div class="need-head"><span class="need-dot" aria-hidden="true"></span>' +
|
|
64
|
+
(opts.compact ? '' : '<a class="need-title" href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' + (s.repoLabel ? '<span class="chip mono">' + esc(s.repoLabel) + '</span>' : '')) +
|
|
65
|
+
(n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote" title="answered on that machine, through its Leftoff">⌂ ' + esc(n.peer || s.machine) + '</span>' : '') +
|
|
66
|
+
'<span class="need-kind">' + esc(KIND_LABEL[n.kind] || '') + '</span>' +
|
|
67
|
+
(n.waitSec != null ? '<span class="need-wait" title="since ' + esc(n.since || '') + '">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + '</div>'
|
|
68
|
+
if (n.kind === 'permission' && n.ask) {
|
|
69
|
+
const d = n.ask.describe || {}
|
|
70
|
+
h += '<div class="need-what"><span class="verb">' + esc(d.verb || n.ask.tool) + '</span><code>' + esc(d.detail || '') + '</code></div>'
|
|
71
|
+
if (d.note) h += '<div class="need-note">' + esc(d.note) + '</div>'
|
|
72
|
+
if (n.more) h += '<div class="need-note">+' + n.more + ' more waiting after this one</div>'
|
|
73
|
+
h += '<div class="need-actions">' +
|
|
74
|
+
'<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
|
|
75
|
+
'<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' title="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>' +
|
|
76
|
+
'<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
|
|
77
|
+
'<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
|
|
78
|
+
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny" data-submit-for="deny"' + dis + '>Deny' + kbdHint(opts) + '</button><button type="button" class="btn" data-action="ask-deny-close">Cancel</button></div>'
|
|
79
|
+
} else if (n.kind === 'question' && n.ask) {
|
|
80
|
+
const qs = n.ask.questions || []
|
|
81
|
+
const oneTap = qs.length === 1 && !qs[0].multiSelect && qs[0].kind === 'choice' && qs[0].options.length
|
|
82
|
+
qs.forEach((q, qi) => {
|
|
83
|
+
h += '<div class="need-q" data-q="' + qi + '" data-multi="' + (q.multiSelect ? '1' : '') + '">' +
|
|
84
|
+
'<div class="qtext">' + (q.header ? '<span class="qhead">' + esc(q.header) + '</span>' : '') + esc(q.question) + '</div>'
|
|
85
|
+
if (q.options.length) {
|
|
86
|
+
h += '<div class="opts">' + q.options.map((o) => '<button type="button" class="btn opt" data-action="' + (oneTap ? 'ask-pick-send' : 'ask-pick') + '" data-label="' + esc(o.label) + '" aria-pressed="false"' + dis + (o.description ? ' title="' + esc(o.description) + '"' : '') + '>' + esc(o.label) + (o.description ? '<small>' + esc(o.description) + '</small>' : '') + '</button>').join('') + '</div>'
|
|
87
|
+
}
|
|
88
|
+
h += '<input type="text" class="qother" maxlength="500" placeholder="' + (q.options.length ? 'Or type your own answer' : 'Your answer') + '" value="' + esc(drafts['q:' + askId + ':' + qi] || '') + '" data-draft="q:' + esc(askId) + ':' + qi + '" data-submit="answer" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>'
|
|
89
|
+
h += '</div>'
|
|
90
|
+
})
|
|
91
|
+
h += '<div class="need-actions">' + (oneTap ? '' : '') + '<button type="button" class="btn primary" data-action="ask-answer" data-choice="answer" data-submit-for="answer"' + dis + '>' + (oneTap ? 'Send typed answer' : 'Send answers') + kbdHint(opts) + '</button>' +
|
|
92
|
+
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + ' title="Decline to answer; Claude carries on without it">Skip</button>' + go + '</div>'
|
|
93
|
+
} else if (n.kind === 'terminal') {
|
|
94
|
+
h += '<div class="need-what"><span class="verb">Claude Code says</span><span>' + esc(n.message || '') + '</span></div>' +
|
|
95
|
+
'<div class="need-note">This one can only be answered in the terminal.' + (n.peerNote ? ' ' + esc(n.peerNote) + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
|
|
96
|
+
} else if (n.kind === 'reply') {
|
|
97
|
+
h += '<blockquote class="need-question">' + esc(n.question || '') + '</blockquote>'
|
|
98
|
+
if (n.replyPending) h += '<div class="need-note ok">Reply sent ' + esc(opts.agoFn ? opts.agoFn(n.replyPending.at) : '') + ': “' + esc(n.replyPending.text) + '”. Claude reads it as its next prompt.</div>'
|
|
99
|
+
if (n.canReply) {
|
|
100
|
+
h += '<div class="reply-row"><textarea class="reply-text" rows="2" maxlength="4000" placeholder="Reply to Claude…" data-draft="reply:' + esc(s.id) + '" data-submit="reply" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>' + esc(drafts['reply:' + s.id] || '') + '</textarea>' +
|
|
101
|
+
'<button type="button" class="btn primary" data-action="reply-send" data-submit-for="reply"' + dis + '>Send' + kbdHint(opts) + '</button></div><div class="need-actions">' + go + '</div>'
|
|
102
|
+
} else {
|
|
103
|
+
h += '<div class="need-note">' + esc(n.why ? 'No reply box: ' + n.why + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!may && n.kind !== 'terminal') h += '<div class="need-note warn">Answering from this device is off. Turn it on in Settings → Privacy & security on the machine running Leftoff, or use Go there.</div>'
|
|
107
|
+
if (busy) h += '<div class="need-note">' + esc(busy) + '</div>'
|
|
108
|
+
return h + '</article>'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// External agents (v0.5.4; reply affordance v0.5.6): what an agent's feed
|
|
112
|
+
// says is waiting on you. Read-only: nothing is answered here. The feed
|
|
113
|
+
// decides whether there is a way to reply (agent.reply { label, url }); with
|
|
114
|
+
// no agent.reply there is no button. A reply link may only be https:, sms:,
|
|
115
|
+
// imessage:, mailto: or tel: (the daemon checks the same list; this is the
|
|
116
|
+
// second check). agent.url only links the agent's chip.
|
|
117
|
+
export const httpsOnly = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' && !x.username && !x.password && x.hostname ? x.href : null } catch { return null } }
|
|
118
|
+
export const REPLY_SCHEMES = ['https:', 'sms:', 'imessage:', 'mailto:', 'tel:']
|
|
119
|
+
export function safeReplyUrl(u) {
|
|
120
|
+
if (typeof u !== 'string' || !u || u.length > 2048) return null
|
|
121
|
+
if (/[\s\u0000-\u001F\u007F<>"'`\\\u202A-\u202E\u2066-\u2069\u200E\u200F]/.test(u)) return null
|
|
122
|
+
const m = /^([a-z][a-z0-9+.-]*:)(.*)$/i.exec(u)
|
|
123
|
+
if (!m) return null
|
|
124
|
+
const scheme = m[1].toLowerCase()
|
|
125
|
+
if (!REPLY_SCHEMES.includes(scheme)) return null
|
|
126
|
+
if (scheme === 'https:') return httpsOnly(u)
|
|
127
|
+
const rest = m[2].replace(/^\/\//, '')
|
|
128
|
+
if (!rest || rest[0] === '?' || rest[0] === '/') return null
|
|
129
|
+
return scheme + m[2]
|
|
130
|
+
}
|
|
131
|
+
// The reply affordance of an agent, or null: { label, url, external }.
|
|
132
|
+
export function agentReply(a) {
|
|
133
|
+
const r = a && a.reply
|
|
134
|
+
if (!r || typeof r !== 'object') return null
|
|
135
|
+
const url = safeReplyUrl(r.url)
|
|
136
|
+
const label = String(r.label == null ? '' : r.label).replace(/[\u0000-\u001F\u007F]+/g, ' ').trim().slice(0, 40)
|
|
137
|
+
if (!url || !label) return null
|
|
138
|
+
return { label, url, external: url.startsWith('https:') }
|
|
139
|
+
}
|
|
140
|
+
// The reply button (or ''). https opens a new tab; sms:/mailto:/tel: hand
|
|
141
|
+
// off to the system app, so no target.
|
|
142
|
+
export function agentReplyButton(a, cls = 'btn primary') {
|
|
143
|
+
const r = agentReply(a)
|
|
144
|
+
if (!r) return ''
|
|
145
|
+
return '<a class="' + esc(cls) + ' agent-reply" href="' + esc(r.url) + '"' + (r.external ? ' target="_blank" rel="noopener noreferrer"' : ' rel="noopener noreferrer"') + '>' + esc(r.label) + (r.external ? ' ↗' : '') + '</a>'
|
|
146
|
+
}
|
|
147
|
+
// The agent's chip; a link to agent.url when the feed gave one.
|
|
148
|
+
export function agentChipHtml(a = {}, { title } = {}) {
|
|
149
|
+
const who = a.display || a.name || 'agent'
|
|
150
|
+
const inner = esc(a.icon || '◆') + ' ' + esc(who)
|
|
151
|
+
const t = esc(title || 'external agent · read-only')
|
|
152
|
+
const url = httpsOnly(a.url)
|
|
153
|
+
return url ? '<a class="chip machine agent" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" title="' + t + '">' + inner + '</a>'
|
|
154
|
+
: '<span class="chip machine agent" title="' + t + '">' + inner + '</span>'
|
|
155
|
+
}
|
|
156
|
+
// "From Tab · reply by texting Tab", or "From Tab · Read-only here".
|
|
157
|
+
export function agentReplyNote(a = {}) {
|
|
158
|
+
const who = a.display || a.name || 'the agent'
|
|
159
|
+
const hint = typeof a.replyHint === 'string' ? a.replyHint.replace(/[\u0000-\u001F\u007F]+/g, ' ').trim().slice(0, 120) : ''
|
|
160
|
+
return 'From ' + who + ' · ' + (hint || 'Read-only here')
|
|
161
|
+
}
|
|
162
|
+
export function agentNeedPanel(n, s = n.session || {}) {
|
|
163
|
+
const a = { ...((s && s.agent) || {}) }
|
|
164
|
+
if (!a.display) a.display = n.agent || 'the agent'
|
|
165
|
+
if (a.reply == null && n.reply) a.reply = n.reply
|
|
166
|
+
if (a.replyHint == null && n.replyHint) a.replyHint = n.replyHint
|
|
167
|
+
const btn = agentReplyButton(a)
|
|
168
|
+
let h = '<article class="need k-agent" data-id="' + esc(s.id) + '" data-key="' + esc('agent:' + s.id) + '">' +
|
|
169
|
+
'<div class="need-head">' + agentChipHtml(a) +
|
|
170
|
+
'<a class="need-title" href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled') + '</a>' +
|
|
171
|
+
'<span class="need-kind">' + esc(KIND_LABEL.agent) + '</span>' + (n.waitSec != null ? '<span class="need-wait">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + '</div>' +
|
|
172
|
+
'<ul class="agent-needs">' + (n.items || []).map((x) => '<li class="plain">' + esc(x.text) + '</li>').join('') + '</ul>' +
|
|
173
|
+
(btn ? '<div class="need-actions">' + btn + '</div>' : '') +
|
|
174
|
+
'<div class="need-note">' + esc(agentReplyNote(a)) + '</div>'
|
|
175
|
+
return h + '</article>'
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function needsStrip(items, opts = {}) {
|
|
179
|
+
if (!items.length) return ''
|
|
180
|
+
const blocking = items.filter((i) => i.blocking).length
|
|
181
|
+
return '<section class="needs-strip" aria-label="Needs you"><div class="needs-head"><h2>Needs you <span class="count">' + items.length + '</span></h2>' +
|
|
182
|
+
'<span class="sub">' + (blocking ? blocking + ' waiting on an answer' : '') + (blocking && items.length > blocking ? ' · ' : '') + (items.length > blocking ? (items.length - blocking) + ' asked you something' : '') + '</span></div>' +
|
|
183
|
+
'<div class="needs-list">' + items.map((n) => needPanel(n, opts)).join('') + '</div></section>'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The answer a question panel holds: picked options, else the typed text.
|
|
187
|
+
// picks: [{ q: index, labels: [..] }], typed: { [index]: text }
|
|
188
|
+
export function questionAnswer(questions = [], picks = {}, typed = {}) {
|
|
189
|
+
const answers = {}
|
|
190
|
+
let other = false
|
|
191
|
+
questions.forEach((q, i) => {
|
|
192
|
+
const t = String(typed[i] || '').trim()
|
|
193
|
+
const p = picks[i] || []
|
|
194
|
+
if (t) { answers[q.question] = q.multiSelect && p.length ? [...p, t] : t; other = true } else if (p.length) answers[q.question] = q.multiSelect ? p : p[0]
|
|
195
|
+
})
|
|
196
|
+
return { answers, allowOther: other }
|
|
197
|
+
}
|
package/ui/offline.css
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
:root { color-scheme: dark light; --bg: #0d1016; --text: #e6e9f0; --muted: #8b93a7; --accent: #7c9cff; --on-accent: #0b0f1a; --line: #252d3d; }
|
|
2
|
+
@media (prefers-color-scheme: light) { :root { --bg: #f5f6f9; --text: #161a24; --muted: #5b6478; --accent: #4560e6; --on-accent: #fff; --line: #e1e5ed; } }
|
|
3
|
+
html, body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
|
4
|
+
main { max-width: 440px; margin: 0 auto; padding: max(48px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right)) max(32px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left)); }
|
|
5
|
+
img { border-radius: 16px; display: block; }
|
|
6
|
+
h1 { font-size: 24px; margin: 20px 0 8px; }
|
|
7
|
+
p, li { color: var(--muted); }
|
|
8
|
+
ul { padding-left: 20px; }
|
|
9
|
+
code { font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px solid var(--line); border-radius: 5px; padding: 0 4px; }
|
|
10
|
+
.btn { display: inline-block; background: var(--accent); color: var(--on-accent); text-decoration: none; padding: 10px 18px; border-radius: 10px; font-weight: 600; margin-top: 8px; }
|
|
11
|
+
.small { font-size: 13px; }
|
package/ui/offline.html
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="color-scheme" content="dark light">
|
|
7
|
+
<title>Leftoff · can't reach it right now</title>
|
|
8
|
+
<link rel="stylesheet" href="/offline.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<main>
|
|
12
|
+
<img src="/icon-192.png" alt="" width="72" height="72">
|
|
13
|
+
<h1>Can't reach Leftoff</h1>
|
|
14
|
+
<p>Leftoff runs on your Mac. A phone reaches it over Tailscale, and right now it can't get through.</p>
|
|
15
|
+
<ul>
|
|
16
|
+
<li>Is Tailscale on, on this phone?</li>
|
|
17
|
+
<li>Is the Mac awake and online?</li>
|
|
18
|
+
<li>Is Leftoff running there? <code>leftoff open</code> starts it.</li>
|
|
19
|
+
</ul>
|
|
20
|
+
<p><a class="btn" href="/">Try again</a></p>
|
|
21
|
+
<p class="small">Leftoff keeps nothing on the phone: the board always comes live from the Mac.</p>
|
|
22
|
+
</main>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
package/ui/pair.html
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<title>Pair this browser · Leftoff</title>
|
|
7
|
+
<meta name="color-scheme" content="dark light">
|
|
8
|
+
<meta name="referrer" content="no-referrer">
|
|
9
|
+
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
|
10
|
+
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
|
11
|
+
<script src="theme.js"></script>
|
|
12
|
+
<link rel="stylesheet" href="app.css">
|
|
13
|
+
</head>
|
|
14
|
+
<body class="pairpage">
|
|
15
|
+
<main class="pairbox">
|
|
16
|
+
<div class="relay-logo">Leftoff</div>
|
|
17
|
+
<h1>Pair this browser</h1>
|
|
18
|
+
<p class="faint">Your board, from anywhere, at <b id="host"></b>. Pair once; the key stays in this browser and on your Macs, never on the relay.</p>
|
|
19
|
+
<form id="form" autocomplete="off">
|
|
20
|
+
<label for="code">Code from your Mac</label>
|
|
21
|
+
<input id="code" class="mono" inputmode="text" autocapitalize="characters" spellcheck="false" placeholder="XXXX-XXXX-XXXX-XXXX" maxlength="24">
|
|
22
|
+
<label for="name">Name this device</label>
|
|
23
|
+
<input id="name" maxlength="60" spellcheck="false">
|
|
24
|
+
<button type="submit" id="go" class="btn primary">Pair</button>
|
|
25
|
+
</form>
|
|
26
|
+
<p id="msg" class="pmsg" role="status"></p>
|
|
27
|
+
<p class="hint">On a Mac: Leftoff → Settings → Phone & remote → Use from anywhere → Pair a phone. Codes work once, for 10 minutes.</p>
|
|
28
|
+
</main>
|
|
29
|
+
<script src="relay-pair.js" type="module"></script>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|