@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/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' ? esc(line.text)
39
- : (line.goal ? '<span class="bl-goal">' + esc(line.goal) + '</span> · ' : '') + esc(line.stopped) + (line.next ? ' <span class="bl-next">Next: ' + esc(line.next) + '</span>' : '')
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.w && r.w.length) {
243
- let at = 0
244
- for (const [s, e] of r.w) {
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> ' + esc(r.text) + '</p>'
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 = esc(b.goal) + (b.stoppedAt ? ' <span class="bc-stop">· stopped at: ' + esc(b.stoppedAt) + '</span>' : '')
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
  }