@leftoff/cli 0.9.17-beta.9 → 0.9.17
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 +19 -1
- package/bin/leftoff.js +484 -474
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +144 -2
- package/ui/app.js +332 -18
- package/ui/back-line.js +5 -2
- package/ui/diff-view.js +71 -10
- package/ui/icons.js +4 -0
- package/ui/recap-line.js +5 -2
- package/ui/semdiff.js +282 -54
- package/ui/session-catchup.js +5 -1
- package/ui/walk-lines.js +119 -0
- package/ui/walk-md.js +40 -0
- package/ui/walk-pop.js +24 -5
- package/ui/walk-spots.js +89 -0
package/ui/diff-view.js
CHANGED
|
@@ -238,19 +238,80 @@ export function renderDiff(doc, text, { label = '', numbers = true } = {}) {
|
|
|
238
238
|
const mk = el('span', 'gd-m', MARK[r.k])
|
|
239
239
|
mk.setAttribute('aria-label', r.k === 'add' ? 'added' : r.k === 'del' ? 'removed' : 'unchanged')
|
|
240
240
|
row.appendChild(mk)
|
|
241
|
+
row.appendChild(lineCell(doc, el, r))
|
|
242
|
+
}
|
|
243
|
+
root.appendChild(row)
|
|
244
|
+
}
|
|
245
|
+
return root
|
|
246
|
+
}
|
|
247
|
+
// a line's text, the changed words marked (textContent only)
|
|
248
|
+
function lineCell(doc, el, r) {
|
|
249
|
+
const c = el('span', 'gd-c')
|
|
250
|
+
if (r.w && r.w.length) {
|
|
251
|
+
let at = 0
|
|
252
|
+
for (const [s, e] of r.w) {
|
|
253
|
+
if (s > at) c.appendChild(doc.createTextNode(r.t.slice(at, s)))
|
|
254
|
+
c.appendChild(el(r.k === 'add' ? 'ins' : 'del', 'gd-x', r.t.slice(s, e)))
|
|
255
|
+
at = e
|
|
256
|
+
}
|
|
257
|
+
if (at < r.t.length) c.appendChild(doc.createTextNode(r.t.slice(at)))
|
|
258
|
+
} else if (r.t) c.textContent = r.t
|
|
259
|
+
else c.textContent = '\u200b' // keeps an empty line's height
|
|
260
|
+
return c
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 2026-10-30 (walkthrough round 3) the same patch side by side: old on the left,
|
|
264
|
+
// new on the right, a run of removed lines beside the added lines that replaced
|
|
265
|
+
// it (paired in order), unchanged lines on both sides, the @@ bars across. -> the
|
|
266
|
+
// split rows: [{ k: 'line', l: rowIndex | null, r: rowIndex | null } | { k: 'span', i }],
|
|
267
|
+
// indexes into parsePatch's rows (so a picked line means the same in both views).
|
|
268
|
+
export function splitRows(rows) {
|
|
269
|
+
const out = []
|
|
270
|
+
let dels = [], adds = []
|
|
271
|
+
const flush = () => { const n = Math.max(dels.length, adds.length); for (let i = 0; i < n; i++) out.push({ k: 'line', l: i < dels.length ? dels[i] : null, r: i < adds.length ? adds[i] : null }); dels = []; adds = [] }
|
|
272
|
+
rows.forEach((r, i) => {
|
|
273
|
+
if (r.k === 'del') { if (adds.length) flush(); dels.push(i) } else if (r.k === 'add') adds.push(i)
|
|
274
|
+
else { flush(); out.push(r.k === 'ctx' ? { k: 'line', l: i, r: i } : { k: 'span', i }) }
|
|
275
|
+
})
|
|
276
|
+
flush()
|
|
277
|
+
return out
|
|
278
|
+
}
|
|
279
|
+
export function renderSplit(doc, text, { label = '' } = {}) {
|
|
280
|
+
const { rows, maxLine } = parsePatch(text)
|
|
281
|
+
const el = (tag, cls, txt) => { const e = doc.createElement(tag); if (cls) e.className = cls; if (txt != null && txt !== '') e.textContent = txt; return e }
|
|
282
|
+
const root = el('div', 'gd gd-split gd-w' + Math.min(6, Math.max(2, String(maxLine || 1).length)))
|
|
283
|
+
if (label) root.setAttribute('aria-label', label)
|
|
284
|
+
const half = (i, side) => {
|
|
285
|
+
if (i == null) { const h = el('div', 'gd-half gd-h' + side + ' gd-empty'); h.setAttribute('aria-hidden', 'true'); return h }
|
|
286
|
+
const r = rows[i]
|
|
287
|
+
const k = r.k === 'ctx' ? 'ctx' : r.k
|
|
288
|
+
const h = el('div', 'gd-half gd-h' + side + ' gd-' + k)
|
|
289
|
+
h.dataset.ri = String(i)
|
|
290
|
+
const num = side === 'l' ? r.o : r.n
|
|
291
|
+
h.appendChild(el('span', 'gd-gut ' + (side === 'l' ? 'gd-o' : 'gd-n'), num == null ? '' : String(num)))
|
|
292
|
+
const mk = el('span', 'gd-m', MARK[k])
|
|
293
|
+
mk.setAttribute('aria-label', k === 'add' ? 'added' : k === 'del' ? 'removed' : 'unchanged')
|
|
294
|
+
h.appendChild(mk)
|
|
295
|
+
h.appendChild(lineCell(doc, el, r))
|
|
296
|
+
return h
|
|
297
|
+
}
|
|
298
|
+
for (const s of splitRows(rows)) {
|
|
299
|
+
if (s.k === 'span') {
|
|
300
|
+
const r = rows[s.i]
|
|
301
|
+
const row = el('div', 'gd-row gd-' + r.k)
|
|
302
|
+
row.dataset.ri = String(s.i)
|
|
303
|
+
row.appendChild(el('span', 'gd-gut gd-span', r.k === 'gap' ? '⋯' : ''))
|
|
241
304
|
const c = el('span', 'gd-c')
|
|
242
|
-
if (r.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (s > at) c.appendChild(doc.createTextNode(r.t.slice(at, s)))
|
|
246
|
-
c.appendChild(el(r.k === 'add' ? 'ins' : 'del', 'gd-x', r.t.slice(s, e)))
|
|
247
|
-
at = e
|
|
248
|
-
}
|
|
249
|
-
if (at < r.t.length) c.appendChild(doc.createTextNode(r.t.slice(at)))
|
|
250
|
-
} else if (r.t) c.textContent = r.t
|
|
251
|
-
else c.textContent = '\u200b' // keeps an empty line's height
|
|
305
|
+
if (r.k === 'gap') c.textContent = r.n + ' unchanged line' + (r.n === 1 ? '' : 's')
|
|
306
|
+
else if (r.k === 'hunk') { c.appendChild(el('span', 'gd-range', r.range)); if (r.ctx) { c.appendChild(doc.createTextNode(' ')); c.appendChild(el('span', 'gd-ctx', r.ctx)) } }
|
|
307
|
+
else c.textContent = r.t
|
|
252
308
|
row.appendChild(c)
|
|
309
|
+
root.appendChild(row)
|
|
310
|
+
continue
|
|
253
311
|
}
|
|
312
|
+
const row = el('div', 'gd-srow')
|
|
313
|
+
row.appendChild(half(s.l, 'l'))
|
|
314
|
+
row.appendChild(half(s.r, 'r'))
|
|
254
315
|
root.appendChild(row)
|
|
255
316
|
}
|
|
256
317
|
return root
|
package/ui/icons.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// so they follow light and dark; always aria-hidden next to a text label.
|
|
4
4
|
// index.html carries the nav's copies inline (no script needed to draw them).
|
|
5
5
|
export const ICONS = {
|
|
6
|
+
"crosshair": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M22 12h-4M6 12H2M12 6V2M12 22v-4\"/>",
|
|
7
|
+
"columns-2": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M12 3v18\"/>",
|
|
8
|
+
"chevrons-down-up": "<path d=\"m7 20 5-5 5 5\"/> <path d=\"m7 4 5 5 5-5\"/>",
|
|
9
|
+
"chevrons-up-down": "<path d=\"m7 15 5 5 5-5\"/> <path d=\"m7 9 5-5 5 5\"/>",
|
|
6
10
|
"panel-left-close": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M9 3v18\"/> <path d=\"m16 15-3-3 3-3\"/>",
|
|
7
11
|
"panel-left-open": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M9 3v18\"/> <path d=\"m14 9 3 3-3 3\"/>",
|
|
8
12
|
"menu": "<path d=\"M4 5h16\"/> <path d=\"M4 12h16\"/> <path d=\"M4 19h16\"/>",
|
package/ui/recap-line.js
CHANGED
|
@@ -3,18 +3,21 @@
|
|
|
3
3
|
// says where the session is (the daemon drops it once you prompt again).
|
|
4
4
|
// Before /clear: what this tab was doing before a /clear, with a link to it.
|
|
5
5
|
// Pure: the board passes its own esc / ago / clock, so this runs under node too.
|
|
6
|
+
// 0.9.19: the words are Claude's, so `code` and **bold** render (escaped first, no links).
|
|
7
|
+
import { mdInline } from './changelog-md.js'
|
|
8
|
+
const md = (t) => mdInline(t, { links: false })
|
|
6
9
|
|
|
7
10
|
export function recapLineHtml(s, { esc, ago = () => '', clock = () => '' }) {
|
|
8
11
|
const r = s && s.recap
|
|
9
12
|
if (!r || !r.text) return ''
|
|
10
13
|
const tip = 'Claude Code’s recap' + (r.at ? ' · ' + ago(r.at) + (clock(r.at) ? ' (' + clock(r.at) + ')' : '') : '')
|
|
11
|
-
return '<p class="recap-line" data-tip="' + esc(tip) + '"><span class="rl-k">Recap</span> ' +
|
|
14
|
+
return '<p class="recap-line" data-tip="' + esc(tip) + '"><span class="rl-k">Recap</span> ' + md(r.text) + '</p>'
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export function beforeClearHtml(s, { esc }) {
|
|
15
18
|
const b = s && s.beforeClear
|
|
16
19
|
if (!b || !b.goal) return ''
|
|
17
20
|
const href = b.sessionId ? '#/s/' + encodeURIComponent(b.sessionId) : null
|
|
18
|
-
const words =
|
|
21
|
+
const words = md(b.goal) + (b.stoppedAt ? ' <span class="bc-stop">· stopped at: ' + md(b.stoppedAt) + '</span>' : '')
|
|
19
22
|
return '<p class="before-clear"><span class="rl-k">Before /clear</span> ' + (href ? '<a href="' + esc(href) + '" data-tip="The session before /clear">' + words + '</a>' : words) + '</p>'
|
|
20
23
|
}
|