@leftoff/cli 0.9.4 → 0.9.6
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 +15 -0
- package/bin/leftoff.js +350 -324
- package/docs/SECURITY.md +1 -0
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +72 -6
- package/mod/hooks/register.js +11 -3
- package/package.json +1 -1
- package/ui/app.css +103 -5
- package/ui/app.js +341 -64
- package/ui/cp-text.js +43 -0
- package/ui/handoffs.js +1 -1
- package/ui/icons.js +2 -1
- package/ui/prs.js +1 -1
- package/ui/settings-dirty.js +27 -0
- package/ui/writing.js +55 -0
package/ui/cp-text.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// 0.9.5: a checkpoint's text can be markdown (a model's write-up kept as it
|
|
2
|
+
// came, see salvageCheckpointReply in mod/hooks/core.js). The session page
|
|
3
|
+
// renders it; cards, the digest and one-line previews show it as plain text,
|
|
4
|
+
// without the ### or ** marks. Shared by the board and the daemon.
|
|
5
|
+
|
|
6
|
+
// Does this read as markdown (headings, lists, bold, links, fences)?
|
|
7
|
+
export function looksMd(s) {
|
|
8
|
+
return /(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s+\S|\d{1,3}[.)]\s+\S|>\s|```)|\*\*[^*\n]+\*\*|__[^_\n]+__|\[[^\]\n]+\]\([^)\s]+\)/.test(String(s == null ? '' : s))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Markdown → one plain line: headings end with ":", list items start with "•",
|
|
12
|
+
// emphasis and link syntax go, `code` stays (the board shows it as code).
|
|
13
|
+
export function stripMd(s) {
|
|
14
|
+
const text = String(s == null ? '' : s).replace(/\r\n?/g, '\n')
|
|
15
|
+
const out = []
|
|
16
|
+
let fence = false
|
|
17
|
+
for (const raw of text.split('\n')) {
|
|
18
|
+
let line = raw.trim()
|
|
19
|
+
if (/^(```|~~~)/.test(line)) { fence = !fence; continue }
|
|
20
|
+
if (!line) continue
|
|
21
|
+
if (!fence) {
|
|
22
|
+
if (/^([-*_])(\s*\1){2,}$/.test(line)) continue // a rule
|
|
23
|
+
let m
|
|
24
|
+
if ((m = line.match(/^#{1,6}\s+(.*?)\s*#*$/))) { line = inline(m[1]); if (line && !/[.:!?…]$/.test(line)) line += ':'; out.push(line); continue }
|
|
25
|
+
line = line.replace(/^>\s?/, '')
|
|
26
|
+
if ((m = line.match(/^[-*+]\s+(.*)$/))) line = '• ' + m[1]
|
|
27
|
+
}
|
|
28
|
+
out.push(fence ? line : inline(line))
|
|
29
|
+
}
|
|
30
|
+
return out.join(' ').replace(/\s+/g, ' ').trim()
|
|
31
|
+
}
|
|
32
|
+
function inline(t) {
|
|
33
|
+
return t
|
|
34
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
35
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
36
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/__([^_]+)__/g, '$1')
|
|
37
|
+
.replace(/(^|[^\w*])\*([^*\s][^*]*?)\*(?![\w*])/g, '$1$2').replace(/(^|[^\w])_([^_\s][^_]*?)_(?!\w)/g, '$1$2')
|
|
38
|
+
.replace(/~~([^~]+)~~/g, '$1')
|
|
39
|
+
.trim()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// For one-line places: plain when it reads as markdown, as it was otherwise.
|
|
43
|
+
export const plainCp = (s) => (looksMd(s) ? stripMd(s) : String(s == null ? '' : s))
|
package/ui/handoffs.js
CHANGED
|
@@ -19,7 +19,7 @@ export function todoRow(t, { agoFn = null, mayEdit = true } = {}) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
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>'
|
|
22
|
+
return '<form class="todo-add addform sess-add" 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
23
|
}
|
|
24
24
|
|
|
25
25
|
// Open to-dos grouped by session (your own, with no session, last).
|
package/ui/icons.js
CHANGED
|
@@ -45,7 +45,8 @@ export const ICONS = {
|
|
|
45
45
|
"pin-off": "<path d=\"M12 17v5\"/> <path d=\"M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89\"/> <path d=\"m2 2 20 20\"/> <path d=\"M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11\"/>",
|
|
46
46
|
"archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
|
|
47
47
|
"bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
|
|
48
|
-
"circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>"
|
|
48
|
+
"circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>",
|
|
49
|
+
"message-square": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>"
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export function icon(name, cls = 'ic') {
|
package/ui/prs.js
CHANGED
|
@@ -60,7 +60,7 @@ export function sessionPrsSection(s, { mayEdit = true } = {}) {
|
|
|
60
60
|
(mayEdit ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(s.id) + '" data-url="' + esc(p.url) + '">Unlink</button>' : '') + '</div>'
|
|
61
61
|
}).join('') + '</div>'
|
|
62
62
|
} 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>'
|
|
63
|
-
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>'
|
|
63
|
+
if (mayEdit) h += '<form class="prlink addform sess-add" 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" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
|
|
64
64
|
return h
|
|
65
65
|
}
|
|
66
66
|
// 0.8.5: why a PR is linked, in words (the daemon sends created / you / branch / manual).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Settings → Save (0.9.5): Save is on only while the form differs from what
|
|
2
|
+
// was last saved. Pure, so the daemon-free unit tests can check the compare.
|
|
3
|
+
|
|
4
|
+
// Deep equality for the plain values the Settings form reads: numbers, strings,
|
|
5
|
+
// booleans, null, arrays and plain objects. A key that is missing equals one
|
|
6
|
+
// that is undefined; NaN equals NaN (an emptied number field reads the same twice).
|
|
7
|
+
export function sameValue(a, b) {
|
|
8
|
+
if (Object.is(a, b)) return true
|
|
9
|
+
if (a === undefined || b === undefined || a === null || b === null) return false
|
|
10
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
11
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
|
12
|
+
return a.every((x, i) => sameValue(x, b[i]))
|
|
13
|
+
}
|
|
14
|
+
if (typeof a !== 'object' || typeof b !== 'object') return false
|
|
15
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
|
|
16
|
+
for (const k of keys) if (!sameValue(a[k], b[k])) return false
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The top-level fields that differ, in the order of `now` (then `saved`).
|
|
21
|
+
export function changedKeys(saved, now) {
|
|
22
|
+
if (!saved || !now) return []
|
|
23
|
+
const keys = [...new Set([...Object.keys(now), ...Object.keys(saved)])]
|
|
24
|
+
return keys.filter((k) => !sameValue(saved[k], now[k]))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const settingsDirty = (saved, now) => changedKeys(saved, now).length > 0
|
package/ui/writing.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// "Checkpoint now" while it's being written (0.9.5): a state that stays on the
|
|
2
|
+
// card and the session page until the checkpoint lands or fails, instead of a
|
|
3
|
+
// toast you can miss. Pure, so the unit tests can drive it with a fake clock.
|
|
4
|
+
//
|
|
5
|
+
// One entry per session id:
|
|
6
|
+
// writing the request is out (this Mac's summarizer or the Mac it lives on)
|
|
7
|
+
// syncing the other Mac took longer than we wait; it shows up when it syncs
|
|
8
|
+
// late ...and still nothing after WRITING_SYNC_MS: a quiet note instead
|
|
9
|
+
// failed it didn't work; the error stays until dismissed or the next try
|
|
10
|
+
|
|
11
|
+
export const WRITING_SYNC_MS = 3 * 60 * 1000
|
|
12
|
+
export const FRESH_MS = 2500
|
|
13
|
+
|
|
14
|
+
export function beginWriting(map, id, { length = null, lastCpId = null, machine = null, now = Date.now() } = {}) {
|
|
15
|
+
map[id] = { phase: 'writing', length, lastCpId, machine, since: now }
|
|
16
|
+
return map[id]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const isWriting = (map, id) => !!(map[id] && (map[id].phase === 'writing' || map[id].phase === 'syncing'))
|
|
20
|
+
|
|
21
|
+
// The request came back: { ok: true }, { slow: true } (the other Mac is still at
|
|
22
|
+
// it) or { error: 'message' }.
|
|
23
|
+
export function settleWriting(map, id, outcome, now = Date.now()) {
|
|
24
|
+
const w = map[id]
|
|
25
|
+
if (!w) return null
|
|
26
|
+
if (outcome && outcome.ok) { delete map[id]; return null }
|
|
27
|
+
if (outcome && outcome.slow) { w.phase = 'syncing'; w.syncSince = now; return w }
|
|
28
|
+
w.phase = 'failed'; w.error = String((outcome && outcome.error) || 'Couldn’t write the checkpoint'); w.at = now
|
|
29
|
+
return w
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Read against the session as it is now. A synced checkpoint newer than the one
|
|
33
|
+
// we started from ends syncing/late (returns { landed: id }); syncing too long
|
|
34
|
+
// turns into late. Writing and failed don't change here.
|
|
35
|
+
export function reconcileWriting(map, s, now = Date.now()) {
|
|
36
|
+
const w = s && map[s.id]
|
|
37
|
+
if (!w) return null
|
|
38
|
+
const newer = s.lastCheckpointId && s.lastCheckpointId !== w.lastCpId
|
|
39
|
+
if ((w.phase === 'syncing' || w.phase === 'late' || w.phase === 'failed') && newer) { delete map[s.id]; return { landed: s.lastCheckpointId } }
|
|
40
|
+
if (w.phase === 'syncing' && now - (w.syncSince || w.since) >= WRITING_SYNC_MS) w.phase = 'late'
|
|
41
|
+
return w
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function dismissWriting(map, id) { if (map[id] && (map[id].phase === 'failed' || map[id].phase === 'late')) delete map[id] }
|
|
45
|
+
|
|
46
|
+
// The words shown. lengthLabel maps 'short' → 'Short'.
|
|
47
|
+
export function writingText(w, lengthLabel = (k) => k) {
|
|
48
|
+
if (!w) return ''
|
|
49
|
+
const len = w.length ? ' · ' + lengthLabel(w.length) : ''
|
|
50
|
+
if (w.phase === 'writing') return 'Writing checkpoint' + len + (w.machine ? ' on ' + w.machine : '') + '…'
|
|
51
|
+
if (w.phase === 'syncing') return (w.machine || 'The other Mac') + ' is still writing it · it shows up here when it syncs…'
|
|
52
|
+
if (w.phase === 'late') return 'Not here yet · it shows up when ' + (w.machine || 'the other Mac') + ' syncs'
|
|
53
|
+
if (w.phase === 'failed') return 'Couldn’t write the checkpoint: ' + w.error
|
|
54
|
+
return ''
|
|
55
|
+
}
|