@leftoff/cli 0.9.17-beta.8 → 0.9.17-beta.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 +2 -0
- package/bin/leftoff.js +299 -299
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +3 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +77 -0
- package/ui/app.js +108 -35
- package/ui/file-reach.js +66 -0
- package/ui/fs-side.js +2 -2
- package/ui/live-view.js +1 -1
- package/ui/prs.js +18 -9
- package/ui/side-fold.js +124 -0
package/ui/side-fold.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// 2026-10-29 A calmer session panel (Joel: "very noisy and SUPER SCROLLY").
|
|
2
|
+
// The top stays open (the head, Catch me up / Walk, the newest checkpoint's
|
|
3
|
+
// Stopped + Next, PRs, To do); everything else is a one-line header with a
|
|
4
|
+
// count ("Files · 12"), closed until you open it. Open/closed is kept per
|
|
5
|
+
// device (localStorage lo-side-fold-v1), not per session. Pure builders,
|
|
6
|
+
// unit-tested (test/unit/calmside-1029.test.mjs); app.js wires them.
|
|
7
|
+
|
|
8
|
+
export const FOLD_KEY = 'lo-side-fold-v1'
|
|
9
|
+
|
|
10
|
+
const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
11
|
+
|
|
12
|
+
// { key: true | false } in one key; anything unreadable is "nothing chosen yet"
|
|
13
|
+
export function createFoldStore(storage) {
|
|
14
|
+
const read = () => { try { const o = JSON.parse((storage && storage.getItem(FOLD_KEY)) || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {} } catch { return {} } }
|
|
15
|
+
return {
|
|
16
|
+
open(key, dflt = false) { const v = read()[key]; return typeof v === 'boolean' ? v : !!dflt },
|
|
17
|
+
set(key, open) {
|
|
18
|
+
if (!key || !/^[\w.-]{1,40}$/.test(key)) return
|
|
19
|
+
const o = read(); o[key] = !!open
|
|
20
|
+
try { storage && storage.setItem(FOLD_KEY, JSON.stringify(o)) } catch {}
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A section as one line: "Files · 12", a chevron; its body under it when open.
|
|
26
|
+
// body: the section's own markup (it keeps its id, so its painter still works).
|
|
27
|
+
export function foldHtml({ key, title, count = null, body = '', open = false, cls = '' }, { esc = escD } = {}) {
|
|
28
|
+
if (!body) return ''
|
|
29
|
+
const n = count == null || count === '' ? '' : '<span class="fs-fold-n"> · ' + esc(String(count)) + '</span>'
|
|
30
|
+
return '<details class="fs-fold' + (cls ? ' ' + cls : '') + '" data-fold="' + esc(key) + '"' + (open ? ' open' : '') + '>' +
|
|
31
|
+
'<summary class="fs-fold-h"><span class="fs-fold-t">' + esc(title) + n + '</span><svg class="fs-fold-c" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m9 6 6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></summary>' +
|
|
32
|
+
'<div class="fs-fold-b">' + body + '</div></details>'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ------------------------------------------------------------ checkpoints
|
|
36
|
+
|
|
37
|
+
// A path as the reader knows it: relative to the session's repo / worktree root,
|
|
38
|
+
// else ~/ for the home folder. /Users/<u>/…/worktrees/<name>/src/a.js -> src/a.js
|
|
39
|
+
export function relPath(p, { roots = [], home = '' } = {}) {
|
|
40
|
+
let s = String(p == null ? '' : p).trim()
|
|
41
|
+
if (!s) return ''
|
|
42
|
+
const rs = roots.filter((r) => typeof r === 'string' && r.length > 1).map((r) => r.replace(/\/+$/, '')).sort((a, b) => b.length - a.length)
|
|
43
|
+
for (const r of rs) if (s === r) return '.'; else if (s.startsWith(r + '/')) return s.slice(r.length + 1)
|
|
44
|
+
// a worktree folder of a tool (…/worktrees/<name>/rest): the rest
|
|
45
|
+
const wt = /^(?:\/Users\/[^/]+|\/home\/[^/]+|~)\/(?:[^/]+\/)*?(?:\.?worktrees|\.claude\/worktrees)\/[^/]+\/(.+)$/.exec(s)
|
|
46
|
+
if (wt) return wt[1]
|
|
47
|
+
const h = String(home || '').replace(/\/+$/, '')
|
|
48
|
+
if (h && s.startsWith(h + '/')) return '~/' + s.slice(h.length + 1)
|
|
49
|
+
const mh = /^\/(?:Users|home)\/[^/]+\/(.+)$/.exec(s)
|
|
50
|
+
if (mh) return '~/' + mh[1]
|
|
51
|
+
return s
|
|
52
|
+
}
|
|
53
|
+
// The roots a session's paths are relative to (worktree first, then repo, then its folder)
|
|
54
|
+
export function rootsOf(s) {
|
|
55
|
+
const r = (s && s.repo) || {}
|
|
56
|
+
return [s && s.worktree && s.worktree.path, r.worktree, r.root, s && s.cwd].filter((x) => typeof x === 'string' && x.startsWith('/'))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Files touched as chips: the first five, then "+N more" that opens the rest.
|
|
60
|
+
export const FILES_SHOWN = 5
|
|
61
|
+
export function filesTouchedHtml(files, { roots = [], home = '' } = {}, { esc = escD } = {}) {
|
|
62
|
+
const list = (Array.isArray(files) ? files : []).map((f) => String(f || '')).filter(Boolean)
|
|
63
|
+
if (!list.length) return ''
|
|
64
|
+
const chip = (f) => '<span class="chip mono" data-tip="' + esc(f) + '">' + esc(relPath(f, { roots, home })) + '</span>'
|
|
65
|
+
const first = list.slice(0, FILES_SHOWN), rest = list.slice(FILES_SHOWN)
|
|
66
|
+
return '<div class="files">' + first.map(chip).join('') +
|
|
67
|
+
(rest.length ? '<details class="files-more"><summary class="linkish">+' + rest.length + ' more</summary>' + rest.map(chip).join('') + '</details>' : '') + '</div>'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Text a checkpoint was written from that wasn't a prompt: Claude Code's
|
|
71
|
+
// wrappers (<task-notification>, <system-reminder>, a slash command's output…),
|
|
72
|
+
// the same list the mod skips (mod/hooks/core.js NOT_A_PROMPT).
|
|
73
|
+
const WRAPPER = /^\s*(?:Working on:\s*[“"]\s*)?(?:<(?:local-command-stdout|local-command-stderr|command-name|command-message|command-args|local-command-caveat|system-reminder|bash-input|bash-stdout|bash-stderr|user-prompt-submit-hook|task-notification|user-memory-input)\b|\[Request interrupted by user|Caveat: The messages below were generated)/
|
|
74
|
+
export function isWrapperCp(cp) {
|
|
75
|
+
if (!cp) return false
|
|
76
|
+
return WRAPPER.test(String(cp.doing || '')) || (!String(cp.doing || '').trim() && WRAPPER.test(String(cp.stoppedAt || '')))
|
|
77
|
+
}
|
|
78
|
+
// Junk: a wrapper checkpoint written within a few minutes of another one (the
|
|
79
|
+
// same moment twice). A lone one stays: it's all there is.
|
|
80
|
+
export const JUNK_NEAR_MS = 5 * 60e3
|
|
81
|
+
export function dropJunkCps(list) {
|
|
82
|
+
const all = Array.isArray(list) ? list : []
|
|
83
|
+
const t = (cp) => Date.parse((cp && cp.createdAt) || '')
|
|
84
|
+
return all.filter((cp) => !(isWrapperCp(cp) && all.some((o) => o !== cp && !isWrapperCp(o) && Math.abs(t(o) - t(cp)) <= JUNK_NEAR_MS)))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ------------------------------------------------------------ status history
|
|
88
|
+
|
|
89
|
+
// One small shape per change, the word and time in its tip (shape + word, never color alone).
|
|
90
|
+
export const STATUS_SHAPE = { needs_input: '▲', active: '●', background: '◐', waiting: '◆', dormant: '○', stale: '◌', ended: '■' }
|
|
91
|
+
export function statusStripHtml(changes, { label = {}, clock = (x) => x } = {}, { esc = escD } = {}) {
|
|
92
|
+
const list = (Array.isArray(changes) ? changes : []).slice(-24)
|
|
93
|
+
if (!list.length) return ''
|
|
94
|
+
return '<div class="sstrip" role="list" aria-label="Status history">' + list.map((c) => {
|
|
95
|
+
const w = label[c.status] || c.status || ''
|
|
96
|
+
const tip = w + ' · ' + clock(c.at)
|
|
97
|
+
return '<span class="ss s-' + esc(c.status) + '" role="listitem" tabindex="0" data-tip="' + esc(tip) + '" aria-label="' + esc(tip) + '">' + esc(STATUS_SHAPE[c.status] || '·') + '</span>'
|
|
98
|
+
}).join('') + '</div>'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ------------------------------------------------------------ answered from the board
|
|
102
|
+
|
|
103
|
+
// Where an answer came from, in the words of what actually happened. Older
|
|
104
|
+
// answers said "over Tailscale" for anything from another device, the relay
|
|
105
|
+
// included (src/answers.js whereFrom before 2026-10-29); they read right now.
|
|
106
|
+
export function whereWords(w) {
|
|
107
|
+
let s = String(w || '').trim()
|
|
108
|
+
if (!s) return ''
|
|
109
|
+
const old = /^over Tailscale(?: as (.+?))?(?: \(through the relay\))?(?: \(relay\))?(?: · (.+))?$/.exec(s)
|
|
110
|
+
if (old && /\(through the relay\)|\(relay\)/.test(s)) return 'from ' + (old[1] || 'a paired device') + ' · via relay' + (old[2] ? ' · ' + old[2] : '')
|
|
111
|
+
return s
|
|
112
|
+
}
|
|
113
|
+
export const ANSWERED_SHOWN = 3
|
|
114
|
+
// The newest three, "Show all N" for the rest (a <details>, so it stays put through redraws)
|
|
115
|
+
export function answeredHtml(answers, { clock = (x) => x } = {}, { esc = escD } = {}) {
|
|
116
|
+
const list = (Array.isArray(answers) ? answers : []).slice().reverse()
|
|
117
|
+
if (!list.length) return ''
|
|
118
|
+
const row = (a) => '<li><span class="t">' + esc(clock(a.at)) + '</span><b>' + esc(a.summary) + '</b>' + (a.what ? ' · <span class="mono">' + esc(String(a.what).slice(0, 160)) + '</span>' : '') +
|
|
119
|
+
'<span class="where">' + esc(whereWords(a.where)) + (a.delivered === false ? ' · not picked up' : '') + '</span></li>'
|
|
120
|
+
const first = list.slice(0, ANSWERED_SHOWN), rest = list.slice(ANSWERED_SHOWN)
|
|
121
|
+
return '<ul class="answered">' + first.map(row).join('') + '</ul>' +
|
|
122
|
+
(rest.length ? '<details class="answered-more"><summary class="linkish">Show all ' + list.length + '</summary><ul class="answered">' + rest.map(row).join('') + '</ul></details>' : '') +
|
|
123
|
+
'<div class="hint">Also in answers.md in this session’s folder.</div>'
|
|
124
|
+
}
|