@leftoff/cli 0.6.9 → 0.7.1
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/CHANGELOG.md +14 -0
- package/bin/leftoff.js +304 -295
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +14 -3
- package/mod/hooks/register.js +22 -4
- package/package.json +1 -1
- package/ui/app.css +81 -0
- package/ui/app.js +119 -15
- package/ui/handoffs.js +184 -0
- package/ui/repos.js +3 -1
package/ui/handoffs.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// To do and Files (0.7.0). Pure HTML builders over what the daemon sends:
|
|
2
|
+
// /v1/needs .todos ({ open, done }) and a session's todos / files / filesSummary.
|
|
3
|
+
// app.js does the fetching and wires the clicks. renderDoc is the viewer's
|
|
4
|
+
// Markdown: everything is escaped first, so raw HTML in a doc shows as text and
|
|
5
|
+
// never runs; links open only for https. Unit tests: test/unit/handoffs-ui.test.mjs.
|
|
6
|
+
import { mdInline } from './changelog-md.js'
|
|
7
|
+
|
|
8
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
9
|
+
const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many || one + 's')
|
|
10
|
+
const safeArtifact = (u) => (/^https:\/\/claude\.ai\/code\/artifact\/[A-Za-z0-9_-]{4,80}$/.test(String(u || '')) ? String(u) : '')
|
|
11
|
+
|
|
12
|
+
// ------------------------------------------------------------------ to-dos
|
|
13
|
+
|
|
14
|
+
export function todoRow(t, { agoFn = null, mayEdit = true } = {}) {
|
|
15
|
+
const codes = (t.codes || []).map((c) => '<button type="button" class="todo-code" data-action="copy-cmd" data-cmd="' + esc(c) + '" title="Copy"><code>' + esc(c.length > 60 ? c.slice(0, 57) + '…' : c) + '</code><span class="todo-cp" aria-hidden="true">Copy</span></button>').join('')
|
|
16
|
+
const meta = [t.manual ? 'you added' : null, agoFn && t.at ? agoFn(t.at) : null].filter(Boolean).join(' · ')
|
|
17
|
+
return '<li class="todo" data-todo="' + esc(t.key) + '"><label class="todo-check"><input type="checkbox" data-todo-done="' + esc(t.key) + '"' + (mayEdit ? '' : ' disabled') + ' aria-label="Done: ' + esc(t.text) + '"><span class="box" aria-hidden="true"></span></label>' +
|
|
18
|
+
'<div class="todo-main"><div class="todo-text">' + mdInline(t.text) + '</div>' + (codes ? '<div class="todo-codes">' + codes + '</div>' : '') + (meta ? '<div class="todo-meta">' + esc(meta) + '</div>' : '') + '</div></li>'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function todoAddForm(sessionId = '') {
|
|
22
|
+
return '<form class="todo-add addform" data-todo-add="' + esc(sessionId || '-') + '" autocomplete="off"><input type="text" name="todo" maxlength="200" placeholder="Add a to-do" aria-label="Add a to-do" enterkeyhint="done"><button type="submit" class="btn sm">Add</button></form>'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Open to-dos grouped by session (your own, with no session, last).
|
|
26
|
+
export function groupTodos(open = []) {
|
|
27
|
+
const groups = new Map()
|
|
28
|
+
for (const t of open) {
|
|
29
|
+
const id = (t.session && t.session.id) || ''
|
|
30
|
+
if (!groups.has(id)) groups.set(id, { session: t.session || null, items: [] })
|
|
31
|
+
groups.get(id).items.push(t)
|
|
32
|
+
}
|
|
33
|
+
return [...groups.values()].sort((a, b) => (!a.session) - (!b.session) || String(b.items[b.items.length - 1].at || '').localeCompare(String(a.items[a.items.length - 1].at || '')))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function doneTodosHtml(done = [], { open = false, agoFn = null } = {}) {
|
|
37
|
+
if (!done.length) return ''
|
|
38
|
+
return '<details class="dismissed todo-done-list"' + (open ? ' open' : '') + '><summary>Done (' + done.length + ')</summary><ul>' +
|
|
39
|
+
done.slice(0, 100).map((d) => '<li><div class="dl-main"><span class="dl-title">' + mdInline(d.text) + '</span>' + (d.session ? '<span class="dl-line">' + esc(d.session.title) + '</span>' : '') +
|
|
40
|
+
(agoFn && d.doneAt ? '<span class="dl-at">done ' + esc(agoFn(d.doneAt)) + '</span>' : '') + '</div>' +
|
|
41
|
+
'<button type="button" class="btn sm" data-action="todo-restore" data-todo="' + esc(d.key) + '">Restore</button></li>').join('') +
|
|
42
|
+
'</ul>' + (done.length > 1 ? '<button type="button" class="linkish" data-action="todo-restore-all">Restore all</button>' : '') + '</details>'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The Needs view's "To do" section.
|
|
46
|
+
export function todosSection(ov, { agoFn = null, mayEdit = true, doneOpen = false, repos = [] } = {}) {
|
|
47
|
+
const open = ((ov && ov.open) || []).filter((t) => !repos.length || (t.session && repos.includes(t.session.repoLabel)) || !t.session)
|
|
48
|
+
const head = '<div class="needs-head todo-head"><h2>To do <span class="count">' + open.length + '</span></h2><span class="sub">Steps sessions handed back to you, and your own</span></div>'
|
|
49
|
+
const body = open.length
|
|
50
|
+
? groupTodos(open).map((g) => '<div class="todo-group">' + (g.session ? '<div class="todo-sess"><a href="#/s/' + encodeURIComponent(g.session.id) + '">' + esc(g.session.title) + '</a>' + (g.session.repoLabel ? '<span class="chip mono">' + esc(g.session.repoLabel) + '</span>' : '') + '</div>' : '<div class="todo-sess"><span>Your to-dos</span></div>') +
|
|
51
|
+
'<ul class="todos">' + g.items.map((t) => todoRow(t, { agoFn, mayEdit })).join('') + '</ul></div>').join('')
|
|
52
|
+
: '<div class="nocp">No to-dos. When a session says you need to do something yourself (run a command, set a secret, add DNS records), it shows up here.</div>'
|
|
53
|
+
return '<section class="todos-sec mt" aria-label="To do">' + head + (mayEdit ? todoAddForm('') : '') + body + doneTodosHtml((ov && ov.done) || [], { open: doneOpen, agoFn }) + '</section>'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A session page's to-dos.
|
|
57
|
+
export function sessionTodosSection(s, { agoFn = null, mayEdit = true } = {}) {
|
|
58
|
+
const list = s.todos || []
|
|
59
|
+
if (!list.length && !mayEdit) return ''
|
|
60
|
+
return '<h2>To do <span class="count">' + list.length + '</span></h2>' +
|
|
61
|
+
(list.length ? '<ul class="todos">' + list.map((t) => todoRow(t, { agoFn, mayEdit })).join('') + '</ul>' : '') + (mayEdit ? todoAddForm(s.id) : '')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ------------------------------------------------------------------ chips
|
|
65
|
+
|
|
66
|
+
export function filesChipLabel(fs, { compact = false } = {}) {
|
|
67
|
+
if (!fs) return ''
|
|
68
|
+
const parts = []
|
|
69
|
+
const docs = (fs.docs || 0) + (fs.plans || 0)
|
|
70
|
+
if (docs) parts.push(compact ? docs + (docs === 1 ? ' doc' : ' docs') : plural(docs, 'doc'))
|
|
71
|
+
if (fs.artifacts) parts.push(plural(fs.artifacts, 'artifact'))
|
|
72
|
+
return parts.join(' · ')
|
|
73
|
+
}
|
|
74
|
+
// The card's small chips: "2 to-dos" and "2 docs · 1 artifact".
|
|
75
|
+
export function cardHandoffChips(s) {
|
|
76
|
+
const n = (s.todos || []).length
|
|
77
|
+
const fl = filesChipLabel(s.filesSummary)
|
|
78
|
+
if (!n && !fl) return ''
|
|
79
|
+
const href = '#/s/' + encodeURIComponent(s.id)
|
|
80
|
+
return '<div class="handoff-chips">' + (n ? '<a class="chip todochip" href="' + href + '" title="Steps handed back to you">☐ ' + esc(plural(n, 'to-do')) + '</a>' : '') +
|
|
81
|
+
(fl ? '<a class="chip fileschip" href="' + href + '" title="Docs and artifacts from this session">' + esc(fl) + '</a>' : '') + '</div>'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ------------------------------------------------------------------ files
|
|
85
|
+
|
|
86
|
+
const TYPE_ICON = { markdown: 'MD', image: 'IMG', pdf: 'PDF', html: 'HTML', text: 'TXT', other: 'FILE' }
|
|
87
|
+
export function sessionFilesSection(s, { agoFn = null } = {}) {
|
|
88
|
+
const f = s.files
|
|
89
|
+
if (!f) return ''
|
|
90
|
+
const arts = (f.artifacts || []).filter((a) => safeArtifact(a.url))
|
|
91
|
+
const plans = f.plans || []
|
|
92
|
+
const docs = f.docs || []
|
|
93
|
+
if (!arts.length && !plans.length && !docs.length && !f.code) return ''
|
|
94
|
+
const when = (at) => (agoFn && at ? '<span class="f-at">' + esc(agoFn(at)) + '</span>' : '')
|
|
95
|
+
let h = '<h2>Files</h2><ul class="files-list">'
|
|
96
|
+
for (const a of arts) h += '<li class="f-art"><a class="f-row" href="' + esc(safeArtifact(a.url)) + '" target="_blank" rel="noopener noreferrer"><span class="f-ic art" aria-hidden="true">◆</span><span class="f-name">' + esc(a.title) + '</span><span class="f-kind">Artifact ↗</span>' + when(a.at) + '</a></li>'
|
|
97
|
+
for (const p of plans) h += '<li><button type="button" class="f-row" data-action="file-open" data-plan="' + esc(p.id) + '" data-session="' + esc(s.id) + '"><span class="f-ic" aria-hidden="true">PLAN</span><span class="f-name">' + esc(p.title) + '</span><span class="f-kind">Plan</span>' + when(p.at) + '</button></li>'
|
|
98
|
+
for (const d of docs) h += '<li><button type="button" class="f-row" data-action="file-open" data-path="' + esc(d.path) + '" data-session="' + esc(s.id) + '"><span class="f-ic" aria-hidden="true">' + (d.plan ? 'PLAN' : TYPE_ICON[d.type] || 'FILE') + '</span><span class="f-name mono">' + esc(d.name) + '</span><span class="f-kind ' + (d.kind === 'new' ? 'new' : '') + '">' + (d.plan ? 'Plan' : d.kind === 'new' ? 'new' : 'edited') + '</span>' + when(d.at) + '</button></li>'
|
|
99
|
+
h += '</ul>'
|
|
100
|
+
if (f.code) h += '<div class="f-code faint">' + esc(plural(f.code, 'code file')) + ' changed</div>'
|
|
101
|
+
return h
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ------------------------------------------------------------------ viewer
|
|
105
|
+
|
|
106
|
+
// Markdown -> safe HTML: headings, paragraphs, lists, task boxes, quotes,
|
|
107
|
+
// fenced code, tables, rules. Inline markup via mdInline (escaped; https links only).
|
|
108
|
+
export function renderDoc(md) {
|
|
109
|
+
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').replace(/^---\n[\s\S]*?\n---\n/, '').split('\n')
|
|
110
|
+
const out = []
|
|
111
|
+
let i = 0
|
|
112
|
+
const para = []
|
|
113
|
+
const flush = () => { if (para.length) { out.push('<p>' + para.map((x) => mdInline(x)).join('<br>') + '</p>'); para.length = 0 } }
|
|
114
|
+
while (i < lines.length) {
|
|
115
|
+
const ln = lines[i]
|
|
116
|
+
let m
|
|
117
|
+
if ((m = ln.match(/^\s*(```|~~~)\s*([\w+-]*)/))) {
|
|
118
|
+
flush()
|
|
119
|
+
const fence = m[1]; const body = []
|
|
120
|
+
i++
|
|
121
|
+
while (i < lines.length && !lines[i].trim().startsWith(fence)) body.push(lines[i++])
|
|
122
|
+
i++
|
|
123
|
+
out.push('<pre class="doc-code"><code>' + esc(body.join('\n')) + '</code></pre>')
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (!ln.trim()) { flush(); i++; continue }
|
|
127
|
+
if ((m = ln.match(/^(#{1,6})\s+(.*)$/))) { flush(); const n = Math.min(6, m[1].length + 1); out.push('<h' + n + '>' + mdInline(m[2].replace(/\s+#+\s*$/, '')) + '</h' + n + '>'); i++; continue }
|
|
128
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(ln)) { flush(); out.push('<hr>'); i++; continue }
|
|
129
|
+
if (/^\s*>/.test(ln)) {
|
|
130
|
+
flush(); const q = []
|
|
131
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) q.push(lines[i++].replace(/^\s*>\s?/, ''))
|
|
132
|
+
out.push('<blockquote>' + renderDoc(q.join('\n')) + '</blockquote>'); continue
|
|
133
|
+
}
|
|
134
|
+
if (/^\s*\|.*\|\s*$/.test(ln) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
|
|
135
|
+
flush()
|
|
136
|
+
const cells = (r) => r.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
|
|
137
|
+
const head = cells(ln); i += 2
|
|
138
|
+
const rows = []
|
|
139
|
+
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]))
|
|
140
|
+
out.push('<div class="doc-table"><table><thead><tr>' + head.map((c) => '<th>' + mdInline(c) + '</th>').join('') + '</tr></thead><tbody>' + rows.map((r) => '<tr>' + r.map((c) => '<td>' + mdInline(c) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>')
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (/^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(ln)) {
|
|
144
|
+
flush()
|
|
145
|
+
const ordered = /^\s*\d/.test(ln)
|
|
146
|
+
const items = []
|
|
147
|
+
while (i < lines.length && (/^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(lines[i]) || (/^\s{2,}\S/.test(lines[i]) && items.length))) {
|
|
148
|
+
const l = lines[i++]
|
|
149
|
+
const mm = l.match(/^(\s*)(?:[-*+]|\d{1,3}[.)])\s+(.*)$/)
|
|
150
|
+
if (mm) items.push({ depth: Math.floor(mm[1].replace(/\t/g, ' ').length / 2), text: mm[2] })
|
|
151
|
+
else items[items.length - 1].text += ' ' + l.trim()
|
|
152
|
+
}
|
|
153
|
+
const li = (t) => { const tm = t.match(/^\[([ xX])\]\s+(.*)$/); return tm ? '<li class="task"><span class="tbox' + (tm[1] !== ' ' ? ' on' : '') + '" aria-hidden="true"></span>' + mdInline(tm[2]) + '</li>' : '<li>' + mdInline(t) + '</li>' }
|
|
154
|
+
out.push('<' + (ordered ? 'ol' : 'ul') + '>' + items.map((it) => (it.depth ? li(it.text).replace('<li', '<li class="sub"') : li(it.text))).join('') + '</' + (ordered ? 'ol' : 'ul') + '>')
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
para.push(ln.trim()); i++
|
|
158
|
+
}
|
|
159
|
+
flush()
|
|
160
|
+
return out.join('\n')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The viewer's body for a file from GET /v1/sessions/:id/file.
|
|
164
|
+
export function docViewerBody(r) {
|
|
165
|
+
if (!r) return ''
|
|
166
|
+
if (r.missing) return '<div class="empty">File no longer exists</div>'
|
|
167
|
+
if (r.tooBig) return '<div class="empty">' + esc(r.message || 'Too big to open here.') + '</div>'
|
|
168
|
+
if (r.type === 'markdown') return '<article class="doc-md">' + renderDoc(r.text || '') + '</article>'
|
|
169
|
+
if (r.type === 'image') {
|
|
170
|
+
const src = r.base64 ? 'data:' + (/^image\/(png|jpeg|gif|webp)$/.test(r.mime) ? r.mime : 'application/octet-stream') + ';base64,' + r.base64
|
|
171
|
+
: r.mime === 'image/svg+xml' && typeof r.text === 'string' ? 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(r.text) : ''
|
|
172
|
+
return src ? '<div class="doc-img"><img alt="' + esc(r.name) + '" src="' + esc(src) + '"></div>' : '<div class="empty">Can’t show this image.</div>'
|
|
173
|
+
}
|
|
174
|
+
if (r.type === 'text' || r.type === 'html') return '<pre class="doc-pre">' + esc(r.text || '') + '</pre>'
|
|
175
|
+
return '<div class="empty">No preview for this kind of file.</div>'
|
|
176
|
+
}
|
|
177
|
+
// A download for anything (an HTML file is offered as a file, never rendered live).
|
|
178
|
+
export function docDownloadHref(r) {
|
|
179
|
+
if (!r || r.missing || r.tooBig) return ''
|
|
180
|
+
const mime = r.type === 'html' ? 'text/plain' : r.mime || 'application/octet-stream'
|
|
181
|
+
if (typeof r.base64 === 'string') return 'data:' + mime + ';base64,' + r.base64
|
|
182
|
+
if (typeof r.text === 'string') return 'data:' + mime + ';charset=utf-8,' + encodeURIComponent(r.text)
|
|
183
|
+
return ''
|
|
184
|
+
}
|
package/ui/repos.js
CHANGED
|
@@ -77,7 +77,9 @@ export function repoMatches(s, token, { loose = false } = {}) {
|
|
|
77
77
|
if (cands.includes(tok)) return true
|
|
78
78
|
// "parent/base" given for a key that is ".../parent/base"
|
|
79
79
|
if (tok.includes('/') && String(key).toLowerCase().endsWith('/' + tok)) return true
|
|
80
|
-
|
|
80
|
+
if (loose && cands.some((c) => c.includes(tok))) return true
|
|
81
|
+
// 0.7.1: the other repos it belongs to (started in, edited in) find it too
|
|
82
|
+
return Array.isArray(s.alsoRepos) && s.alsoRepos.some((r) => r && r.key && repoMatches({ repoKey: r.key, repoLabel: r.label }, token, { loose }))
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
// The CLI's --repo: exact matches win; with none, a substring.
|