@leftoff/cli 0.6.5 → 0.6.9
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 +37 -0
- package/bin/leftoff.js +272 -269
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +122 -5
- package/mod/hooks/register.js +31 -8
- package/package.json +1 -1
- package/ui/app.css +91 -3
- package/ui/app.js +299 -64
- package/ui/index.html +1 -0
- package/ui/prs.js +128 -0
- package/ui/relay-client.js +7 -1
- package/ui/repos.js +2 -0
- package/ui/settings-nav.js +2 -2
package/ui/index.html
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
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
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
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="#/prs" data-route="prs" id="prsTab"><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="18" cy="18" r="3"/> <circle cx="6" cy="6" r="3"/> <path d="M13 6h3a2 2 0 0 1 2 2v7"/> <line x1="6" x2="6" y1="9" y2="21"/></svg><span class="lbl">PRs</span></a>
|
|
32
33
|
<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
34
|
<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
35
|
</nav>
|
package/ui/prs.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The PRs tab and PR rows (0.6.9). Pure HTML builders, no fetching: app.js
|
|
2
|
+
// loads /v1/prs and /v1/sessions/:id and paints with these. Sessions and PRs
|
|
3
|
+
// are many-to-many; a row shows every session it is linked to.
|
|
4
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
5
|
+
const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
|
|
6
|
+
|
|
7
|
+
export function shortAgo(iso, now = Date.now()) {
|
|
8
|
+
const t = Date.parse(iso || '')
|
|
9
|
+
if (!t) return ''
|
|
10
|
+
const s = Math.max(0, Math.round((now - t) / 1000))
|
|
11
|
+
if (s < 60) return 'now'
|
|
12
|
+
if (s < 3600) return Math.round(s / 60) + 'm'
|
|
13
|
+
if (s < 86400) return Math.round(s / 3600) + 'h'
|
|
14
|
+
return Math.round(s / 86400) + 'd'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ✓ 3/3, ✕ 1 failing, ◌ 2 running — the class says which.
|
|
18
|
+
export function checksHtml(ck, { compact = false } = {}) {
|
|
19
|
+
if (!ck || !ck.total) return ''
|
|
20
|
+
const cls = ck.failed ? 'fail' : ck.pending ? 'pending' : 'pass'
|
|
21
|
+
const ic = { fail: '✕', pending: '◌', pass: '✓' }[cls]
|
|
22
|
+
const full = ck.summary || (ck.total - ck.failed - ck.pending) + '/' + ck.total + ' checks passed'
|
|
23
|
+
// On a card: ✕ 1/3 (failing of all), ◌ 1/2 (passed so far), ✓ 3/3.
|
|
24
|
+
const short = (ck.failed ? ck.failed : ck.total - ck.failed - ck.pending) + '/' + ck.total
|
|
25
|
+
return '<span class="pr-checks ' + cls + '"' + (compact ? ' title="' + esc(full) + '" aria-label="' + esc(full) + '"' : '') + '><span aria-hidden="true">' + ic + '</span> ' + esc(compact ? short : full) + '</span>'
|
|
26
|
+
}
|
|
27
|
+
const STATE = { open: 'Open', draft: 'Draft', merged: 'Merged', closed: 'Closed' }
|
|
28
|
+
export function stateHtml(state) {
|
|
29
|
+
if (!state) return ''
|
|
30
|
+
return '<span class="pr-state ' + esc(state) + '">' + esc(STATE[state] || state) + '</span>'
|
|
31
|
+
}
|
|
32
|
+
const REVIEW = { approved: 'Approved', changes_requested: 'Changes requested', review_required: 'Review needed' }
|
|
33
|
+
|
|
34
|
+
// A session card's PRs: one compact line each (#, title, state, checks), three at most.
|
|
35
|
+
export function cardPrsHtml(prs) {
|
|
36
|
+
const list = (prs || []).filter((p) => p && p.number)
|
|
37
|
+
if (!list.length) return ''
|
|
38
|
+
const shown = list.slice(0, 3)
|
|
39
|
+
const more = list.length - shown.length
|
|
40
|
+
return '<div class="card-prs">' + shown.map((p) => {
|
|
41
|
+
const url = safeUrl(p.url)
|
|
42
|
+
const inner = '<span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + '<span class="pr-title">' + esc(p.title || p.repo || '') + '</span>' + checksHtml(p.checks, { compact: true })
|
|
43
|
+
return url ? '<a class="cpr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" title="' + esc((p.repo ? p.repo + ' ' : '') + '#' + p.number + ' on GitHub') + '">' + inner + '</a>' : '<span class="cpr">' + inner + '</span>'
|
|
44
|
+
}).join('') + (more > 0 ? '<a class="cpr more" href="#/prs">+' + more + ' more</a>' : '') + '</div>'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
|
|
48
|
+
export function sessionPrsSection(s, { mayEdit = true } = {}) {
|
|
49
|
+
const list = (s.prs || []).filter((p) => p && p.number)
|
|
50
|
+
let h = '<h2>Pull requests' + (list.length ? ' <span class="count">' + list.length + '</span>' : '') + '</h2>'
|
|
51
|
+
if (list.length) {
|
|
52
|
+
h += '<div class="prlist">' + list.map((p) => {
|
|
53
|
+
const url = safeUrl(p.url)
|
|
54
|
+
return '<div class="prrow slim"><div class="pr-main">' +
|
|
55
|
+
'<div class="pr-line1"><span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) + '</div>' +
|
|
56
|
+
'<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || 'Pull request #' + p.number) + ' ↗</a>' : esc(p.title || '')) + '</div>' +
|
|
57
|
+
'<div class="pr-meta">' + esc(p.repo || '') + (p.via ? ' · ' + esc(VIA[p.via] || p.via) : '') + '</div></div>' +
|
|
58
|
+
(mayEdit ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(s.id) + '" data-url="' + esc(p.url) + '">Unlink</button>' : '') + '</div>'
|
|
59
|
+
}).join('') + '</div>'
|
|
60
|
+
} else h += '<div class="nocp">No pull requests linked yet. They link on their own from gh pr create, PR links in the chat, or the branch.</div>'
|
|
61
|
+
if (mayEdit) h += '<form class="prlink" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false"><button type="submit" class="btn">Link</button></form>'
|
|
62
|
+
return h
|
|
63
|
+
}
|
|
64
|
+
const VIA = { created: 'opened here', mentioned: 'mentioned', branch: 'this branch', manual: 'linked by hand' }
|
|
65
|
+
|
|
66
|
+
// gh missing or signed out: the 0.6.5 status line with the fix to copy.
|
|
67
|
+
export function ghLineHtml(g) {
|
|
68
|
+
if (!g || g.status === 'ready') return ''
|
|
69
|
+
const what = g.status === 'signed-out' ? 'gh is installed but not signed in to GitHub, so PRs can’t load.' : 'GitHub’s gh isn’t on this Mac, so PRs can’t load.'
|
|
70
|
+
let h = '<div class="ghcell prs-gh"><div class="ghrow"><span class="chip warn">' + (g.status === 'signed-out' ? 'Signed out' : 'Missing') + '</span><span class="ghwhat">' + what + '</span>' +
|
|
71
|
+
'<button type="button" class="linkish" data-action="prs-refresh">Check again</button></div>'
|
|
72
|
+
if (g.fix && g.fix.cmd) h += '<div class="joinline ghfix"><code class="cmd">' + esc(g.fix.cmd) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(g.fix.cmd) + '">Copy</button></div>'
|
|
73
|
+
if (g.fix && g.fix.url) h += '<div class="hint">Install gh from <a href="' + esc(g.fix.url) + '" target="_blank" rel="noopener">cli.github.com</a>, then run this.</div>'
|
|
74
|
+
return h + '</div>'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sessionLinks(r, opts) {
|
|
78
|
+
const ss = r.sessions || []
|
|
79
|
+
if (!ss.length) return ''
|
|
80
|
+
return '<div class="pr-sessions">' + ss.slice(0, 4).map((s) => {
|
|
81
|
+
const act = opts.sessionAction ? opts.sessionAction(s) : ''
|
|
82
|
+
return '<span class="pr-sess"><a href="#/s/' + encodeURIComponent(s.id) + '" class="sesslink">' + esc(s.title || 'Untitled session') + '</a>' + (s.machine && opts.showMachine ? '<span class="faint"> · ' + esc(s.machine) + '</span>' : '') + act + '</span>'
|
|
83
|
+
}).join('') + (ss.length > 4 ? '<span class="faint">+' + (ss.length - 4) + '</span>' : '') + '</div>'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// One PR row on the PRs tab.
|
|
87
|
+
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false } = {}) {
|
|
88
|
+
const p = r.pr || {}
|
|
89
|
+
const url = safeUrl(p.url)
|
|
90
|
+
const line1 = '<span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) +
|
|
91
|
+
(r.attention && r.attention !== 'Checks failing' ? '<span class="pr-att">' + esc(r.attention) + '</span>' : p.review && REVIEW[p.review] ? '<span class="pr-review ' + esc(p.review) + '">' + REVIEW[p.review] + '</span>' : '')
|
|
92
|
+
const meta = team
|
|
93
|
+
? [p.author ? '@' + p.author : '', p.createdAt ? 'opened ' + shortAgo(p.createdAt, now) + ' ago' : '', p.branch || ''].filter(Boolean)
|
|
94
|
+
: [p.repo, p.branch, p.state === 'merged' && p.mergedAt ? 'merged ' + shortAgo(p.mergedAt, now) + ' ago' : p.updatedAt ? 'updated ' + shortAgo(p.updatedAt, now) + ' ago' : ''].filter(Boolean)
|
|
95
|
+
const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
|
|
96
|
+
return '<article class="prrow" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
|
|
97
|
+
'<div class="pr-line1">' + line1 + '</div>' +
|
|
98
|
+
'<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
|
|
99
|
+
'<div class="pr-meta">' + meta.map(esc).join(' · ') + '</div>' +
|
|
100
|
+
sessionLinks(r, { sessionAction, showMachine }) + '</div>' +
|
|
101
|
+
'<div class="pr-actions">' + (url ? '<a class="btn sm" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">GitHub ↗</a>' : '') +
|
|
102
|
+
(unlinkTo ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(unlinkTo.id) + '" data-url="' + esc(p.url) + '" title="Unlink from this session">Unlink</button>' : '') + '</div></article>'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The whole tab: Mine / Team switch, the gh line, groups.
|
|
106
|
+
export function prsPageHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null } = {}) {
|
|
107
|
+
let h = '<div class="pagehead"><div><h1>Pull requests</h1><div class="sub">' + (view === 'team' ? 'Every open PR in the repos you work in.' : 'PRs linked to your sessions.') + '</div></div></div>'
|
|
108
|
+
h += '<div class="filters prs-filters"><div class="seg" role="group" aria-label="Whose PRs">' +
|
|
109
|
+
[['mine', 'Mine'], ['team', 'Team']].map(([k, l]) => '<button type="button" data-action="prs-view" data-view="' + k + '" aria-pressed="' + (view === k) + '">' + l + '</button>').join('') +
|
|
110
|
+
'</div><button type="button" class="linkish" data-action="prs-refresh">Refresh</button></div>'
|
|
111
|
+
if (!d) return h + '<div class="empty">Loading pull requests…</div>'
|
|
112
|
+
if (d.error) return h + '<div class="banner warn"><span>' + esc(d.error) + '</span></div>'
|
|
113
|
+
if (d.gh && d.gh.status !== 'ready') return h + ghLineHtml(d.gh)
|
|
114
|
+
const groups = (d.groups || []).filter((g) => g.rows.length || (view === 'team' && g.failed))
|
|
115
|
+
const showMachine = !!d.multiMachine
|
|
116
|
+
if (!groups.length) {
|
|
117
|
+
h += '<div class="empty">' + (d.loading ? 'Loading pull requests…' : view === 'team' ? (d.repos && d.repos.length ? 'No open PRs in ' + esc(d.repos.join(', ')) + '.' : 'None of your sessions are in a GitHub repo yet.') : 'No PRs linked to your sessions yet. They link on their own when a session runs gh pr create, mentions a PR, or works on a branch with an open PR.') + '</div>'
|
|
118
|
+
} else {
|
|
119
|
+
for (const g of groups) {
|
|
120
|
+
h += '<section class="prgroup' + (g.key === 'attention' ? ' attention' : '') + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>'
|
|
121
|
+
if (g.failed && !g.rows.length) h += '<div class="hint">Couldn’t load this repo’s PRs right now.</div>'
|
|
122
|
+
h += g.rows.map((r) => prRowHtml(r, { team: view === 'team', now, mayEdit, sessionAction, showMachine })).join('') + '</section>'
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const foot = [d.servedBy && d.machine && d.servedBy !== d.machine ? 'From ' + d.servedBy + '’s gh' : '', d.loading ? 'Updating…' : d.updatedAt ? 'Updated ' + shortAgo(d.updatedAt, now) + (shortAgo(d.updatedAt, now) === 'now' ? '' : ' ago') : ''].filter(Boolean)
|
|
126
|
+
if (foot.length) h += '<div class="prs-foot faint">' + esc(foot.join(' · ')) + '</div>'
|
|
127
|
+
return h
|
|
128
|
+
}
|
package/ui/relay-client.js
CHANGED
|
@@ -152,9 +152,13 @@ function needsKey() {
|
|
|
152
152
|
screen('Pair this browser again', '<p>This browser has a pass for ' + esc(RELAY_USER) + '.leftoff.dev but not the key that unlocks your board (it lives only in this browser, and it’s gone: private browsing, cleared website data, or another browser).</p>' +
|
|
153
153
|
'<p>On a Mac: Settings → Phone & remote → <b>Pair a phone</b>, then scan the QR code.</p><p><a class="btn primary" href="/pair.html">I have a code</a></p>')
|
|
154
154
|
}
|
|
155
|
+
let notPairedShown = false
|
|
155
156
|
function notPaired(revoked) {
|
|
157
|
+
// once shown, a later 401 (the event stream, a poll) doesn't redraw it
|
|
158
|
+
if (notPairedShown) return
|
|
159
|
+
notPairedShown = true
|
|
156
160
|
forgetDeviceKey()
|
|
157
|
-
screen('This browser isn’t paired anymore', '<p>' + (revoked ? 'It was revoked on one of your
|
|
161
|
+
screen('This browser isn’t paired anymore', '<p>' + (revoked === 'self' ? 'You revoked it.' : revoked ? 'It was revoked on one of your devices.' : 'It was revoked, or its pass expired.') + ' Pair it again from a Mac: Settings → Phone & remote → <b>Pair a phone</b>.</p><p><a class="btn primary" href="/pair.html">I have a code</a></p>')
|
|
158
162
|
}
|
|
159
163
|
let asleepShown = false
|
|
160
164
|
function asleep() {
|
|
@@ -175,6 +179,8 @@ export const leftoffRelay = {
|
|
|
175
179
|
status: () => refreshStatus(),
|
|
176
180
|
// Switch Macs: the board reloads from the other one.
|
|
177
181
|
pick(mac) { remember(mac); cookieMac(mac); location.reload() },
|
|
182
|
+
// This browser revoked itself (0.6.6).
|
|
183
|
+
unpaired() { notPaired('self') },
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
if (RELAY_USER) {
|
package/ui/repos.js
CHANGED
|
@@ -95,6 +95,8 @@ export const STATUS_FILTERS = {
|
|
|
95
95
|
waiting: ['waiting'],
|
|
96
96
|
dormant: ['dormant', 'stale'],
|
|
97
97
|
ended: ['ended'],
|
|
98
|
+
// 0.6.7: every ended session, not just today's (the Board's Closed view)
|
|
99
|
+
closed: ['ended'],
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export const STORE_KEY = 'cp.repos'
|
package/ui/settings-nav.js
CHANGED
|
@@ -9,10 +9,10 @@ export const SETTINGS = [
|
|
|
9
9
|
{ key: 'agents', label: 'Agents', icon: 'bot', sub: 'Work from AI assistants outside Claude Code.', words: 'tab feed agent key url host hosting add refresh remove' },
|
|
10
10
|
{ key: 'models', label: 'Models & summaries', icon: 'sparkles', sub: 'Who writes your checkpoints, and how long they are.', words: 'summarizer provider model ollama anthropic openai claude haiku sonnet opus api key server url length short long informative', save: true },
|
|
11
11
|
{ key: 'phone', label: 'Phone & remote', icon: 'smartphone', sub: 'Your board on your iPhone, from anywhere or over Tailscale.', words: 'use from anywhere relay leftoff.dev subdomain pair pairing device devices revoke tailscale remote access qr code sign in signed iphone home screen url app' },
|
|
12
|
-
{ key: 'security', label: 'Privacy & security', icon: 'shield', sub: '
|
|
12
|
+
{ key: 'security', label: 'Privacy & security', icon: 'shield', sub: 'Trust my paired devices, and machine keys.', words: 'answer answering permission prompts trust trusted paired devices phone other machines go keys fingerprint privacy data protected encryption advanced', save: true },
|
|
13
13
|
{ key: 'updates', label: 'Updates & about', icon: 'info', sub: 'Version, updates and this Mac.', words: 'update upgrade version check about machine folder what’s new whats new changelog', save: true },
|
|
14
14
|
]
|
|
15
|
-
export const SETTINGS_ALIAS = { anywhere: 'phone', relay: 'phone', remote: 'phone', tailscale: 'phone', iphone: 'phone', machines: 'security', answering: 'security', privacy: 'security', summaries: 'models', summarizer: 'models', model: 'models', ai: 'models', syncthing: 'sync', pairing: 'sync', pair: 'sync', about: 'updates', update: 'updates', version: 'updates', checkpoints: 'general', appearance: 'general', theme: 'general', notify: 'general' }
|
|
15
|
+
export const SETTINGS_ALIAS = { anywhere: 'phone', relay: 'phone', remote: 'phone', tailscale: 'phone', iphone: 'phone', machines: 'security', answering: 'security', trust: 'security', privacy: 'security', summaries: 'models', summarizer: 'models', model: 'models', ai: 'models', syncthing: 'sync', pairing: 'sync', pair: 'sync', about: 'updates', update: 'updates', version: 'updates', checkpoints: 'general', appearance: 'general', theme: 'general', notify: 'general' }
|
|
16
16
|
// The section a route asks for: #/settings/<key>, or the old ?<key> form
|
|
17
17
|
// (#/settings?spaces, ?sync, ?remote…); null means the list.
|
|
18
18
|
export function settingsKeyFor(arg, paramKeys = []) {
|