@leftoff/cli 0.9.17-beta.9 → 0.9.18-beta.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/CHANGELOG.md +34 -0
- package/bin/leftoff.js +541 -487
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +150 -2
- package/ui/app.js +384 -30
- package/ui/ask.js +1 -0
- package/ui/back-line.js +5 -2
- package/ui/diff-view.js +71 -10
- package/ui/icons.js +4 -0
- package/ui/live-view.js +3 -3
- package/ui/recap-line.js +5 -2
- package/ui/semdiff.js +284 -56
- package/ui/session-catchup.js +5 -1
- package/ui/start.js +87 -12
- 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/ask.js
CHANGED
|
@@ -94,6 +94,7 @@ export function byLabel(t, { machine = null } = {}) {
|
|
|
94
94
|
if (!by) return ''
|
|
95
95
|
const on = machine ? ' on ' + machine : ''
|
|
96
96
|
if (by.engine === 'claude') return 'Claude Code (' + (by.model || 'sonnet') + ', read-only)' + on + (by.readRepo === false ? ', from your notes only' : '')
|
|
97
|
+
if (by.engine === 'codex') return 'Codex (read-only)' + (by.unavailable ? ' (Claude unavailable: ' + by.unavailable + ')' : '') + on + (by.readRepo === false ? ', from your notes only' : '') // 0.9.17
|
|
97
98
|
if (by.engine === 'summary') return 'From your notes (no repo reading), by ' + (by.model || by.provider || 'your summary model') + on
|
|
98
99
|
if (by.engine === 'findings') return 'What I found (no model answered)' + on
|
|
99
100
|
if (by.engine === 'empty') return ''
|
package/ui/back-line.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// × hides that one line for this tab (sessionStorage); a new recap or
|
|
6
6
|
// checkpoint brings it back. Pure apart from the store the board passes.
|
|
7
7
|
|
|
8
|
+
import { mdInline } from './changelog-md.js'
|
|
9
|
+
// 0.9.19: the words are Claude's or the summarizer's, so `code` and **bold** render (escaped first, no links)
|
|
10
|
+
const md = (x) => mdInline(x, { links: false })
|
|
8
11
|
const MIN = 60000
|
|
9
12
|
export const BACK_AWAY_MS = 30 * MIN
|
|
10
13
|
export const BACK_PREFIX = 'lo-back-x:'
|
|
@@ -35,8 +38,8 @@ export function backLineHtml(s, line, { esc, icon = () => '', ago = () => '' })
|
|
|
35
38
|
const when = line.at ? ago(line.at) : ''
|
|
36
39
|
const label = line.kind === 'recap' ? 'Recap' : 'Where it stopped'
|
|
37
40
|
const tip = (line.kind === 'recap' ? 'Claude Code’s recap' : 'The latest checkpoint') + (when ? ' · ' + when : '')
|
|
38
|
-
const body = line.kind === 'recap' ?
|
|
39
|
-
: (line.goal ? '<span class="bl-goal">' +
|
|
41
|
+
const body = line.kind === 'recap' ? md(line.text)
|
|
42
|
+
: (line.goal ? '<span class="bl-goal">' + md(line.goal) + '</span> · ' : '') + md(line.stopped) + (line.next ? ' <span class="bl-next">Next: ' + md(line.next) + '</span>' : '')
|
|
40
43
|
return '<div class="back-line" role="note" data-key="' + esc(line.key) + '"><p data-tip="' + esc(tip) + '"><span class="rl-k">' + label + '</span> ' + body + '</p>' +
|
|
41
44
|
'<button type="button" class="back-x" data-action="back-dismiss" data-id="' + esc(s.id) + '" data-key="' + esc(line.key) + '" aria-label="Hide this line" data-tip="Hide">' + icon('x') + '</button></div>'
|
|
42
45
|
}
|
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/live-view.js
CHANGED
|
@@ -20,7 +20,7 @@ export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.en
|
|
|
20
20
|
// a thread while it is live-linked and the how-to otherwise.
|
|
21
21
|
// -> 'none' | 'thread' | 'readonly' (a stopped headless one: no composer) | 'how'
|
|
22
22
|
export function liveBoxKind(s) {
|
|
23
|
-
if (!s || s.agent || s.codingAgent === 'codex') return 'none'
|
|
23
|
+
if (!s || s.agent || (s.codingAgent === 'codex' && !s.headless)) return 'none' // 1.0: headless Codex has Live
|
|
24
24
|
if (s.capture === 'hooks' && !s.headless) return 'none'
|
|
25
25
|
if (s.liveLink && s.liveLink.on) return 'thread'
|
|
26
26
|
if (s.headless) return 'thread' // 0.9.13: not running (Paused, Stopped): a message resumes it
|
|
@@ -123,7 +123,7 @@ const LIVE_TIP = 'Live link: send this session messages from the board or your p
|
|
|
123
123
|
export function liveBadgeHtml(s, now = Date.now()) {
|
|
124
124
|
if (!cardPills(s).live) return ''
|
|
125
125
|
const w = liveWorking(s, now)
|
|
126
|
-
return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? 'Claude is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
|
|
126
|
+
return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? (s.codingAgent === 'codex' ? 'Codex' : 'Claude') + ' is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
// The board redraws a card (innerHTML) on every update, which would restart a
|
|
@@ -503,7 +503,7 @@ export function pendingClaudeHtml(you, { esc, ago, now = Date.now(), lvActs = nu
|
|
|
503
503
|
if (st.phase === 'stopped') inner = (st.why && st.why.label ? whyHtml(st.why, { esc }) : '<div class="lv-text lv-none">' + esc(st.lost ? 'No reply recorded' : 'Stopped without a reply') + '</div>') + acts + toolsHtml(st, { esc, now })
|
|
504
504
|
else if (st.phase === 'replied') inner = '<div class="lv-text lv-none">Replied in the terminal</div>' + toolsHtml(st, { esc, now }) // settled, its words not kept: never a Working bubble
|
|
505
505
|
else inner = workingHtml(st, { esc, now })
|
|
506
|
-
return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st) + (acts ? '|a' + actsSig(lvActs) : ''))) + '"><div class="lv-who">Claude ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
|
|
506
|
+
return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st) + (acts ? '|a' + actsSig(lvActs) : ''))) + '"><div class="lv-who">' + esc(you.answerBy || 'Claude') + ' ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
|
|
507
507
|
}
|
|
508
508
|
|
|
509
509
|
// The whole thread: each message you sent, then Claude's side of that turn.
|
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
|
}
|