@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/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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
- return loose && cands.some((c) => c.includes(tok))
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.