@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.11

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/live-view.js CHANGED
@@ -7,6 +7,7 @@ import { statusView, elapsedText, ranWithoutAsking, isQueued } from './live-stat
7
7
  import { parseAttachedLines } from './attach.js'
8
8
  import { stripChannel } from './cp-text.js' // 0.9.9: the one Live-wrapper helper
9
9
  import { repoDisplay } from './repos.js' // one repo name everywhere
10
+ import { displayTitle, promptTitleText, whereLine } from './session-title.js' // 2026-11-05 one title helper
10
11
  import { copyButtonHtml } from './copy-msg.js' // 0.9.11: Copy on Claude's messages
11
12
 
12
13
  const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
@@ -51,10 +52,13 @@ export function pastEmptyText(s, v, { mac = 'this Mac' } = {}) {
51
52
  export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
52
53
  if (!s) return untitled
53
54
  const wrapped = isWrapped(s.title)
54
- if (s.title && !wrapped) return s.title
55
+ // 2026-11-05: what the session is doing, tidied (ui/session-title.js); where
56
+ // it runs is sessionWhere's line, never glued to the front of the name
57
+ if (s.title && !wrapped) return displayTitle(s.title, { source: s.titleSource }) || s.title
55
58
  // No name yet (or a raw <channel …> one an earlier beta kept): the first
56
59
  // prompt's words, which another Mac's older Leftoff may have sent without a title.
57
- const first = promptLine(s.firstPrompt) || (wrapped ? promptLine(s.title) : '')
60
+ const raw = promptRaw(s.firstPrompt) || (wrapped ? promptRaw(s.title) : '')
61
+ const first = raw ? promptTitleText(raw) || promptLine(raw) : ''
58
62
  if (first && (isLiveSession(s) || wrapped)) return first
59
63
  if (isLiveSession(s)) {
60
64
  const repo = repoShown(s) || null
@@ -66,6 +70,13 @@ export function sessionTitle(s, { untitled = 'Untitled session' } = {}) {
66
70
  return untitled
67
71
  }
68
72
 
73
+ // The muted second line under a session's name: where it runs, "folder@branch"
74
+ // ('' when the name already is just that, or nothing is known).
75
+ export function sessionWhere(s) {
76
+ if (!s || titleIsWhere(s)) return ''
77
+ return whereLine(s, repoShown(s))
78
+ }
79
+
69
80
  // Is the card's name just where it runs ("repo · branch")? Then the repo chip
70
81
  // under it would say the same thing twice.
71
82
  export function titleIsWhere(s) {
@@ -82,6 +93,11 @@ const isWrapped = (t) => /^\s*<channel\b/.test(String(t || ''))
82
93
  const NOISE_LINE = /^\s*<\/?(local-command-\w+|command-\w+|system-reminder|pasted_content)\b/
83
94
  // The first line of a prompt in its own words: no channel wrapper (even one
84
95
  // cut short), no system reminders; 60 characters at most.
96
+ function promptRaw(text) {
97
+ let t = stripChannel(text).replace(/<system-reminder>[\s\S]*?(<\/system-reminder>|$)/g, '')
98
+ t = t.split('\n').map((l) => l.trim()).find((l) => l && !NOISE_LINE.test(l)) || ''
99
+ return t.replace(/\s+/g, ' ')
100
+ }
85
101
  export function promptLine(text) {
86
102
  let t = stripChannel(text).replace(/<system-reminder>[\s\S]*?(<\/system-reminder>|$)/g, '')
87
103
  t = t.split('\n').map((l) => l.trim()).find((l) => l && !NOISE_LINE.test(l)) || ''
@@ -246,7 +262,8 @@ export function fullBarHtml(id, { esc, icon, pills = '', stop = '', retitle = ''
246
262
  // 0.9.18 phones: the nav as a drawer (ui/navrail.js); from 700px the rail is beside full screen and this hides
247
263
  '<button type="button" class="btn lf-menu" data-nav-drawer aria-label="Menu" aria-expanded="false" aria-controls="navDrawer" data-tip="Menu">' + icon('menu') + '</button>' +
248
264
  '<button type="button" class="btn live-fs-x" data-action="live-full-exit" data-id="' + esc(id) + '" aria-label="Back to the board" data-tip="Board">' + icon('chevron-left') + '</button>' + // 0.9.16 one layout: back is the board
249
- glyph + '<h2 class="lf-title"></h2>' + retitle + '<div class="lf-pills">' + pills + '</div>' + stop +
265
+ // 2026-11-05: what it's doing, then folder@branch muted under it
266
+ glyph + '<div class="lf-tt"><h2 class="lf-title"></h2><span class="lf-where"></span></div>' + retitle + '<div class="lf-pills">' + pills + '</div>' + stop +
250
267
  // 0.9.15 the embedded terminal (ui/term.js): docked under full screen, which gets shorter
251
268
  '<button type="button" class="btn lf-term" data-term-toggle aria-label="Terminal" data-tip="Terminal (⌃`)">' + icon('terminal') + '</button></div>'
252
269
  }
@@ -309,7 +326,9 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
309
326
  const who = senderLabel(m)
310
327
  const title = m.from === 'you' ? senderTitle(m) : ''
311
328
  let body
329
+ const cmd = isCommandEntry(m)
312
330
  if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
331
+ else if (cmd) body = commandHtml(m, { esc })
313
332
  else if (m.from === 'you') {
314
333
  const a = messageAttachments(m)
315
334
  // 0.9.9 Queued: a long one folds to two lines; a tap opens it in place
@@ -335,6 +354,8 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
335
354
  // 0.9.11: Copy, once the reply is whole (none while it streams)
336
355
  const cp = m.done ? copyButtonHtml(m.text) : ''
337
356
  if (cp) foot += '<div class="msg-foot">' + cp + '</div>'
357
+ } else if (cmd && m.status && m.status.phase === 'replied') {
358
+ // ran: what it printed is the body's; nothing to add
338
359
  } else if (m.status && m.from === 'you' && isQueued(m.status)) {
339
360
  foot = queuedHtml(m, { esc, may })
340
361
  } else if (m.status) {
@@ -352,6 +373,16 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
352
373
  return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + (queued ? ' lv-queued' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '">' + head + body + foot + '</li>'
353
374
  }
354
375
 
376
+ // A slash command sent from the board (ui/slash-commands.js; src/live-link.js sendCommand): the command
377
+ // in mono, then what Claude Code printed, once the transcript shows it ran (settleCommands).
378
+ export function isCommandEntry(m) { return !!m && m.from === 'you' && m.command === true }
379
+ export function commandHtml(m, { esc }) {
380
+ const ran = !!(m.status && m.status.phase === 'replied')
381
+ const out = typeof m.out === 'string' ? m.out.trim() : ''
382
+ return '<div class="lv-text lv-cmd"><code>' + esc(m.text || '') + '</code></div>' +
383
+ (out ? '<pre class="lv-cmd-out">' + esc(out) + '</pre>' : ran ? '<div class="lv-cmd-out lv-cmd-done">Done</div>' : '')
384
+ }
385
+
355
386
  // 0.9.15: an answer that is still the Stop hook's words, clipped by the mod (280 characters ending in "…")
356
387
  export function partialAnswer(m) { return !!(m && m.from === 'claude' && m.via === 'turn' && typeof m.text === 'string' && /…\s*$/.test(m.text)) }
357
388
 
@@ -548,7 +579,7 @@ export function threadHtml(msgs, opts) {
548
579
  // 0.9.14: Edit on the message; Retry on Claude's "stopped" card (or on the message, when it didn't arrive)
549
580
  const acts = m === tgt ? { retry: m.status.phase === 'failed', edit: true, busy } : null
550
581
  html += entryHtml(m, acts ? { ...opts, lvActs: acts } : opts)
551
- if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') {
582
+ if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent' && !isCommandEntry(m)) { // a command has no turn: what it printed is in its own bubble
552
583
  // the asks of this turn go before Claude's Working bubble: the answer that replaces it in place then lands after them, as on a fresh page
553
584
  html += upTo(next == null ? Infinity : next)
554
585
  html += pendingClaudeHtml(m, m === tgt ? { ...opts, lvActs: { retry: true, busy } } : opts)
@@ -647,7 +678,7 @@ export { ranWithoutAsking }
647
678
  // A content signature, to replace only the entries that changed (the time-ago
648
679
  // text is left out so a minute ticking over doesn't redraw a reply).
649
680
  function sigHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
650
- export function entrySig(m) { return JSON.stringify([m.text, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable, !!m.status.sendNow], m.done, m.ranWithoutAsking, m.ranWhy || null, m.errorText, m.via === 'turn' ? 1 : 0]) }
681
+ export function entrySig(m) { return JSON.stringify([m.text, m.out == null ? null : m.out, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable, !!m.status.sendNow], m.done, m.ranWithoutAsking, m.ranWhy || null, m.errorText, m.via === 'turn' ? 1 : 0]) }
651
682
 
652
683
  // ------------------------------------------------------------ the DOM keeper
653
684
 
package/ui/prs.js CHANGED
@@ -182,8 +182,11 @@ function sessionLinks(r, opts) {
182
182
 
183
183
  // One PR row on the PRs tab. 0.8.5: tap the row to expand it in place
184
184
  // (`open`, with `detail` as /v1/prs/detail answered: { loading } | { ok, detail } | { ok: false, code }).
185
- export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null } = {}) {
185
+ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null, picks = null } = {}) {
186
186
  const p = r.pr || {}
187
+ // testplan Part 3: a merged PR can be picked for a test plan (a box plus the word, the state in aria-pressed)
188
+ const picked = !!(picks && picks.has(p.url))
189
+ const pick = picks && p.state === 'merged' ? '<button type="button" class="pr-pick" data-action="prs-pick" data-url="' + esc(p.url) + '" aria-pressed="' + picked + '" data-tip="' + (picked ? 'Picked for a test plan' : 'Pick for a test plan') + '"><span aria-hidden="true">' + (picked ? '☑' : '☐') + '</span><span class="pr-pick-w">' + (picked ? 'Picked' : 'Pick') + '</span></button>' : ''
187
190
  const url = safeUrl(p.url)
188
191
  // 0.8.9: what the checks and review chips don't already say (a conflict, your review)
189
192
  const bwhy = r.bucketWhy !== undefined ? r.bucketWhy : prBucket(p, me).why
@@ -195,7 +198,7 @@ export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, s
195
198
  : [p.repo, p.branch, when].filter(Boolean)
196
199
  const why = whyText(r)
197
200
  const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
198
- return '<article class="prrow' + (open ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
201
+ return '<article class="prrow' + (open ? ' open' : '') + (pick ? ' pickable' : '') + (picked ? ' picked' : '') + '" data-pr="' + esc(p.url) + '">' + pick + '<div class="pr-main">' +
199
202
  '<div class="pr-line1">' + line1 + '</div>' +
200
203
  '<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
201
204
  '<div class="pr-meta">' + meta.map(esc).join(' · ') + (why ? ' · <span class="pr-why">' + esc(why) + '</span>' : '') + prMoreHtml(p, open) + '</div>' +
@@ -411,12 +414,19 @@ export const isOpenPr = (r) => !['merged', 'closed'].includes(String((r && r.pr
411
414
  function doneGroupHtml(g, { open, rowOpts }) {
412
415
  return '<details class="prgroup done b-' + g.key + '" data-group="' + g.key + '"' + (open ? ' open' : '') + '><summary><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span><span class="chev" aria-hidden="true">›</span></h2></summary>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</details>'
413
416
  }
414
- export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false, doneOpen = null, projects = [] } = {}) {
417
+ // testplan Part 3: what's picked, and the way to a test plan of exactly those
418
+ export function prPicksBarHtml(picks) {
419
+ const n = picks ? picks.size : 0
420
+ if (!n) return ''
421
+ const href = '#/testplan/new?prs=' + [...picks].map(encodeURIComponent).join(',')
422
+ return '<div class="prs-picks" role="status"><span><span aria-hidden="true">☑</span> ' + n + ' merged PR' + (n === 1 ? '' : 's') + ' picked</span><a class="btn primary" href="' + esc(href) + '">Test plan for these</a><button type="button" class="linkish" data-action="prs-pick-clear">Clear</button></div>'
423
+ }
424
+ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false, doneOpen = null, projects = [], picks = null } = {}) {
415
425
  const f = { repos: [], state: null, q: '', project: null, ...filter }
416
426
  if (f.project && !(projects || []).some((p) => p.id === f.project)) f.project = null
417
427
  const all = prRows(d)
418
428
  const me = d.me || null
419
- let h = prsCutHtml(d) // 0.9.12: first, so a phone sees it
429
+ let h = prsCutHtml(d) + prPicksBarHtml(picks) // 0.9.12: first, so a phone sees it
420
430
  if (all.length) {
421
431
  // 0.9.0: projects first (when you have any), then the repos in the picked one
422
432
  if ((projects || []).length) {
@@ -449,7 +459,7 @@ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
449
459
  const rows = filterPrRows(all, f, { me, projects })
450
460
  const { groups, quiet } = groupPrRows(rows, { now, me })
451
461
  const showMachine = !!d.multiMachine
452
- const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
462
+ const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null, picks: view === 'mine' ? picks : null })
453
463
  if (!all.length && d.tooBig) {
454
464
  h += '<div class="empty">Too many PRs to load at once. <button type="button" class="linkish" data-action="prs-refresh">Try again</button></div>'
455
465
  } else if (!all.length) {
package/ui/pull.js CHANGED
@@ -7,7 +7,7 @@ export const PULL_MAX = 110 // how far the spinner travels at most
7
7
 
8
8
  // Where a pull never starts: text fields, the Live box, menus and sheets,
9
9
  // sideways scrollers, and anything open on top of the page.
10
- export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, [data-no-pull]'
10
+ export const PULL_SKIP = 'input, textarea, [contenteditable=""], [contenteditable="true"], .live-box, lo-select, lo-combo, .lo-menu, .lo-backdrop, .cmdk, .quicklook, .moremenu, .statuspop, .doc-viewer, .emoji-sheet-wrap, .relay-screen, .tabs, pre, .scroll-x, .fs-side, [data-no-pull]'
11
11
 
12
12
  // The phone layout on a touch screen, at the very top of the page, from a
13
13
  // place that isn't a field or an overlay, and with no overlay open.
@@ -15,9 +15,22 @@ export function canStartPull({ coarse, narrow, scrollY, target, overlayOpen } =
15
15
  if (!coarse || !narrow || overlayOpen) return false
16
16
  if (scrollY > 0) return false
17
17
  if (target && typeof target.closest === 'function' && target.closest(PULL_SKIP)) return false
18
+ if (scrolledAbove(target)) return false
18
19
  return true
19
20
  }
20
21
 
22
+ // 2026-11-06 (the phone's session sheet "scrolls down okay, but I cannot scroll back up. It's very
23
+ // jumpy"): in full screen the page itself never scrolls (scrollY is 0), so a finger moving down
24
+ // inside the sheet's own scroller was taken as a pull to refresh: the board cancelled the scroll and
25
+ // showed the spinner instead. A pull only starts where nothing between the finger and the page is
26
+ // itself scrolled down: a scroller with room above means the finger is scrolling it back up.
27
+ export function scrolledAbove(el) {
28
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
29
+ if ((Number(n.scrollTop) || 0) > 0) return true
30
+ }
31
+ return false
32
+ }
33
+
21
34
  // One finger movement: how far the spinner sits (with some resistance), how
22
35
  // far round it is, and whether letting go now refreshes. Sideways first means
23
36
  // it isn't a pull.
package/ui/semdiff.js CHANGED
@@ -83,8 +83,11 @@ export function readingOrder(steps, data) {
83
83
  return (steps || []).slice().sort((a, b) => ri(a) - ri(b) || tagRank(a) - tagRank(b) || (a.n || 0) - (b.n || 0))
84
84
  }
85
85
  // A repo the session changed that couldn't be read here: one quiet line each, never dropped silently
86
+ // 2026-11-03 and what it left out because it was already changed before the session (baseNotes)
86
87
  export function unreadableLines(data) {
87
- return data && Array.isArray(data.unreadable) ? data.unreadable.map((u) => (u && (u.line || (u.repo ? u.repo + ': not readable from here' + (u.why ? ' (' + u.why + ')' : '') : ''))) || '').filter(Boolean) : []
88
+ const un = data && Array.isArray(data.unreadable) ? data.unreadable.map((u) => (u && (u.line || (u.repo ? u.repo + ': not readable from here' + (u.why ? ' (' + u.why + ')' : '') : ''))) || '').filter(Boolean) : []
89
+ const bn = data && Array.isArray(data.baseNotes) ? data.baseNotes.map((n) => (n && typeof n.line === 'string' ? n.line : '')).filter(Boolean) : []
90
+ return un.concat(bn)
88
91
  }
89
92
  function unreadableHtml(data, { esc, icon }) {
90
93
  const ls = unreadableLines(data)
@@ -139,9 +142,11 @@ export function snipsByFile(st) {
139
142
  return out
140
143
  }
141
144
  // the files of a walkthrough that start folded: generated or lockfiles, and anything in Safe to skip
145
+ // (2026-11-03 not "Tests updated to match": a test a step names, like the one that pins the bug,
146
+ // is part of that step's story even when the rest of its lines landed in that group)
142
147
  export function skipFiles(data) {
143
148
  const out = new Set()
144
- for (const g of (data && data.groups) || []) for (const f of g.files || []) out.add(f.path)
149
+ for (const g of (data && data.groups) || []) if (g.kind !== 'tests') for (const f of g.files || []) out.add(f.path)
145
150
  for (const f of (data && data.files) || []) if (f.generated) out.add(f.path)
146
151
  return out
147
152
  }
@@ -160,10 +165,78 @@ export function sfSet(fp, keys, open) {
160
165
  if (all.length > 30) for (const k of all.filter((x) => x !== SF_KEY + fp).slice(0, all.length - 30)) localStorage.removeItem(k)
161
166
  } catch {}
162
167
  }
163
- export function sfOpen(state, step, file, skip) { const k = step + '|' + file; return k in (state || {}) ? !!state[k] : fileFoldsDefault(file, skip) }
168
+ // def: the default when nothing was chosen (null: by the file's kind)
169
+ export function sfOpen(state, step, file, skip, def = null) { const k = step + '|' + file; return k in (state || {}) ? !!state[k] : def != null ? !!def : fileFoldsDefault(file, skip) }
170
+ // 2026-11-03 ("Where's my diff?": steps 1 and 2 both named one new test file; step 1's row came up
171
+ // empty and its diff showed in step 2 only). Two steps naming exactly the same lines had one id,
172
+ // so the one drawn diff moved to the later step. Walkthroughs kept before the fix still carry the
173
+ // shared id: the later ones get their count appended, as src/semdiff.js uniqueStepIds does. In place.
174
+ export function uniqueStepIds(steps) {
175
+ const seen = new Set()
176
+ for (const st of steps || []) {
177
+ if (!st || typeof st.id !== 'string' || !st.id) continue
178
+ if (!seen.has(st.id)) { seen.add(st.id); continue }
179
+ let k = 2
180
+ while (seen.has(st.id + k.toString(16))) k++
181
+ st.id = st.id + k.toString(16)
182
+ seen.add(st.id)
183
+ }
184
+ return steps
185
+ }
186
+ // The same lines shown again by a later step: "<step id>:<snippet i>" -> { id, n } of the first
187
+ // step (in reading order) that shows them. That first step shows them open; a later one folds
188
+ // that file with "Also in step N", which goes there.
189
+ export function sharedSnips(data) {
190
+ const first = new Map(), out = new Map()
191
+ for (const st of readingOrder((data && data.steps) || [], data)) {
192
+ ;(st.snippets || []).forEach((sn, i) => {
193
+ const k = sn.hunk + '\0' + sn.patch + '\0' + JSON.stringify(sn.focus || null)
194
+ const f = first.get(k)
195
+ if (!f) first.set(k, { id: st.id, n: st.n })
196
+ else if (f.id !== st.id) out.set(st.id + ':' + i, f)
197
+ })
198
+ }
199
+ return out
200
+ }
201
+ // "Lines 12–30", "Lines 3–5, 9" (old side: "Old lines …"), or '' with no focus
202
+ export function focusWords(focus) {
203
+ if (!Array.isArray(focus) || !focus.length) return ''
204
+ const r = (f) => (f.from === f.to ? String(f.from) : f.from + '–' + f.to)
205
+ const nw = focus.filter((f) => f.side !== 'old'), od = focus.filter((f) => f.side === 'old')
206
+ const parts = []
207
+ if (nw.length) parts.push((nw.length === 1 && nw[0].from === nw[0].to ? 'Line ' : 'Lines ') + nw.map(r).join(', '))
208
+ if (od.length) parts.push((parts.length ? 'old ' : 'Old ') + (od.length === 1 && od[0].from === od[0].to ? 'line ' : 'lines ') + od.map(r).join(', '))
209
+ return parts.join(', ')
210
+ }
211
+ // a file of a step whose every snippet an earlier step shows: that step, else null
212
+ export function alsoIn(st, g, shared) {
213
+ if (!shared || !shared.size || !g.snips.length) return null
214
+ const fs = g.snips.map(([, i]) => shared.get(st.id + ':' + i))
215
+ return fs.every((f) => f && f.id === fs[0].id) ? fs[0] : null
216
+ }
164
217
  export const CHANGED_TIP = 'Its files changed since this walkthrough was written'
165
218
 
166
- function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null }) {
219
+ // 2026-11-03 decision memory: the accepted decisions a step cites, as small chips that open the decision with its chain
220
+ export function decisionsOf(data) { return new Map(((data && data.decisions) || []).map((d) => [d.id, d])) }
221
+ export function stepDecisionsHtml(st, data, { esc }) {
222
+ const ids = Array.isArray(st.decisions) ? st.decisions : []
223
+ if (!ids.length) return ''
224
+ const by = decisionsOf(data)
225
+ const gone = new Set((data && data.stale && data.stale.decisions) || [])
226
+ const departs = new Set(Array.isArray(st.departs) ? st.departs : [])
227
+ return '<div class="wt-decs" role="group" aria-label="Decisions behind this step">' + ids.map((id) => {
228
+ const d = by.get(id)
229
+ const dep = departs.has(id), prop = !!(d && d.status === 'proposed')
230
+ // 1g/1f: shape plus word, never color alone: ≠ Departs, ◌ Proposed
231
+ const marks = (dep ? '<span class="wt-dec-rel wt-dec-dep"><span aria-hidden="true">≠</span> Departs</span>' : '') + (prop ? '<span class="wt-dec-rel wt-dec-prop"><span aria-hidden="true">◌</span> Proposed</span>' : '')
232
+ const tip = (gone.has(id) ? 'Changed since this walkthrough · ' : '') + (dep ? 'This step departs from it · ' : '') + (prop ? 'Proposed by this session, not accepted yet · ' : '') + (d ? d.text : 'Decision ' + id)
233
+ return '<button type="button" class="dec-chip wt-dec' + (gone.has(id) ? ' wt-dec-gone' : '') + (dep ? ' wt-dec-departs' : '') + (prop ? ' wt-dec-proposed' : '') + '" data-action="wt-dec" data-id="' + esc(id) + '"' + (dep ? ' data-departs="1"' : '') + (data && data.decisionRepo ? ' data-repo="' + esc(data.decisionRepo) + '"' : '') +
234
+ ' data-tip="' + esc(tip) + '"><span class="wt-dec-id">' + esc(id) + '</span>' + marks + (d ? '<span class="wt-dec-t">' + esc(d.text) + '</span>' : '') + (gone.has(id) ? '<span class="wt-dec-w"> · changed since</span>' : '') + '</button>'
235
+ }).join('') + '</div>'
236
+ }
237
+ // how the export names a cited decision: "departs from" / "(proposed)"
238
+ function decWord(st, id, d) { return (Array.isArray(st.departs) && st.departs.includes(id) ? 'departs from ' : '') + (d && d.status === 'proposed' ? '(proposed) ' : '') }
239
+ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, stat = null, shared = null, foldShared = true, data = null }) {
167
240
  const core = st.tag === 'Core'
168
241
  // 2026-10-29 several repos: the step's repo leads ("cube_cloud · src/x.ts"); one repo: the file names
169
242
  const rp = st.repo ? [st.repo] : []
@@ -175,15 +248,17 @@ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, s
175
248
  '<button type="button" class="wt-rev' + (done ? ' on' : '') + '" data-action="wt-reviewed" data-id="' + esc(sid) + '" data-step="' + esc(st.id) + '" aria-pressed="' + (done ? 'true' : 'false') + '" data-tip="' + (done ? 'Reviewed · tap to undo' : 'Mark reviewed') + '">' + icon('check') + '<span>' + (done ? 'Reviewed' : 'Mark reviewed') + '</span></button></header>'
176
249
  const why = st.why ? '<div class="wt-why wt-md">' + walkBlock(st.why) + '</div>' : ''
177
250
  const byFile = snipsByFile(st)
178
- const fileHead = (g, isOpen) => '<summary class="wt-sf-h" aria-label="' + esc((st.repo ? repoSplit(g.file, rp).filter(Boolean).join(' · ') : g.file) + ', ' + g.add + ' added, ' + g.del + ' removed') + '">' + icon('chevron-right', 'ic wt-g-chev') +
251
+ const fileHead = (g, isOpen, also) => '<summary class="wt-sf-h" aria-label="' + esc((st.repo ? repoSplit(g.file, rp).filter(Boolean).join(' · ') : g.file) + ', ' + g.add + ' added, ' + g.del + ' removed' + (also ? ', also in step ' + also.n : '')) + '">' + icon('chevron-right', 'ic wt-g-chev') +
179
252
  (stat && stat.get(g.file) ? statusHtml(stat.get(g.file), { esc, icon }) : '') +
180
253
  '<span class="mono wt-sf-p">' + esc(st.repo ? repoSplit(g.file, rp).filter(Boolean).join(' · ') : g.file) + '</span>' +
254
+ (also ? '<a class="wt-also" href="#wt-' + esc(also.id) + '" data-action="wt-go" data-step="' + esc(also.id) + '" data-file="' + esc(g.file) + '" data-tip="The same lines, open in step ' + also.n + '">' + icon('arrow-up') + 'Also in step ' + also.n + '</a>' : '') +
181
255
  '<span class="wt-file-n"><span class="wt-add">+' + n0(g.add) + '</span> <span class="wt-del">−' + n0(g.del) + '</span></span></summary>'
182
256
  const snips = (byFile.length > 1 ? '<div class="wt-sfa" role="group" aria-label="Files in this step"><button type="button" class="linkish wt-sfa-b" data-action="wt-sf-all" data-step="' + esc(st.id) + '" data-open="0">' + icon('chevrons-down-up') + 'Collapse all</button><button type="button" class="linkish wt-sfa-b" data-action="wt-sf-all" data-step="' + esc(st.id) + '" data-open="1">' + icon('chevrons-up-down') + 'Expand all</button></div>' : '') +
183
257
  byFile.map((g) => {
184
- const isOpen = sfOpen(sf, st.id, g.file, skip)
185
- return '<details class="wt-sf" data-sfk="' + esc(st.id + '|' + g.file) + '" data-step="' + esc(st.id) + '"' + (isOpen ? ' open' : '') + '>' + fileHead(g, isOpen) +
186
- g.snips.map(([sn, i]) => '<div class="wt-snip">' + (sn.partial ? '<div class="wt-snip-h"><span class="faint">Part of the change</span></div>' : '') + '<div class="wt-snip-d" data-snip="' + esc(st.id) + ':' + i + '"></div></div>').join('') + '</details>'
258
+ const also = alsoIn(st, g, shared)
259
+ const isOpen = sfOpen(sf, st.id, g.file, skip, also && foldShared ? false : null)
260
+ return '<details class="wt-sf' + (also ? ' wt-sf-also' : '') + '" data-sfk="' + esc(st.id + '|' + g.file) + '" data-step="' + esc(st.id) + '"' + (isOpen ? ' open' : '') + '>' + fileHead(g, isOpen, also) +
261
+ g.snips.map(([sn, i]) => '<div class="wt-snip">' + (sn.partial ? '<div class="wt-snip-h"><span class="faint">Part of the change</span></div>' : focusWords(sn.focus) ? '<div class="wt-snip-h"><span class="faint wt-focus-w"><i class="wt-focus-g" aria-hidden="true"></i>' + esc(focusWords(sn.focus)) + ' for this step</span></div>' : '') + '<div class="wt-snip-d" data-snip="' + esc(st.id) + ':' + i + '"></div></div>').join('') + '</details>'
187
262
  }).join('')
188
263
  const show = core || open
189
264
  const lines = st.snippets.reduce((a, s) => a + s.changed, 0)
@@ -191,7 +266,7 @@ function stepHtml(st, { esc, icon, done, cur, open, sid, sf = {}, skip = null, s
191
266
  : '<button type="button" class="wt-showc" data-action="wt-code" data-step="' + esc(st.id) + '" aria-expanded="false">' + icon('chevron-right') + 'Show code <span class="faint">· ' + plural(lines, 'line') + '</span></button>'
192
267
  // callouts whose line isn't in a drawn snippet (folded code) still read here
193
268
  const loose = !show && st.callouts.length ? '<div class="wt-cos">' + st.callouts.map((c) => calloutHtml(c, { esc, icon })).join('') + '</div>' : ''
194
- return '<article class="wt-st ' + (core ? 'wt-core' : 'wt-sup') + (done ? ' done' : '') + (cur ? ' cur' : '') + '" id="wt-' + esc(st.id) + '" data-step="' + esc(st.id) + '" tabindex="-1" aria-label="' + esc('Step ' + st.n + ': ' + mdPlain(st.title)) + '">' + head + why + loose + code + '</article>'
269
+ return '<article class="wt-st ' + (core ? 'wt-core' : 'wt-sup') + (done ? ' done' : '') + (cur ? ' cur' : '') + '" id="wt-' + esc(st.id) + '" data-step="' + esc(st.id) + '" tabindex="-1" aria-label="' + esc('Step ' + st.n + ': ' + mdPlain(st.title)) + '">' + head + why + stepDecisionsHtml(st, data, { esc }) + loose + code + '</article>'
195
270
  }
196
271
 
197
272
  function groupHtml(g, { esc, icon, open, data = null }) {
@@ -334,6 +409,8 @@ function modeSegHtml(show, split) {
334
409
  // "3 files changed since this walkthrough · Update"
335
410
  export function staleWords(st) {
336
411
  if (!st) return ''
412
+ // 2026-11-03 a decision it cites has changed (superseded, rejected or edited) since it was written
413
+ if (Array.isArray(st.decisions) && st.decisions.length && !st.count) return (st.decisions.length === 1 ? 'Decision ' + st.decisions[0] + ' has' : 'Decisions ' + st.decisions.join(', ') + ' have') + ' changed since this walkthrough'
337
414
  if (st.count == null) return 'The change has moved on since this walkthrough'
338
415
  if (!st.count) return 'The change has moved on since this walkthrough'
339
416
  return st.count + (st.count === 1 ? ' file changed' : ' files changed') + ' since this walkthrough'
@@ -371,9 +448,10 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
371
448
  exportsHtml(ok, ui, { icon }) + '</div>' +
372
449
  '<button type="button" class="wt-x" data-action="wt-close" data-id="' + esc(sid) + '" aria-label="Close the walkthrough">' + icon('x') + '</button></div>'
373
450
  // 2026-10-30 the reading tools, a slim second row of the toolbar (the first stays one row): Next spot, Unified / Split
374
- const tools = spotBtnHtml(ok && view === 'story' ? spotsFor(data, readingOrder(data.steps, data)).length : 0, ui.spot, { icon }) +
451
+ // 2026-11-03 This session · Whole folder, at the top of the story and the Files list alike
452
+ const tools = fileScopeSegHtml(data && data.fileScope, { esc, icon, action: 'wt-files', busy: !!ui.loading }) + spotBtnHtml(ok && view === 'story' ? spotsFor(data, readingOrder(data.steps, data)).length : 0, ui.spot, { icon }) +
375
453
  modeSegHtml(ok || (data && Array.isArray(data.files) && data.files.length), ui.split)
376
- if (tools) h += '<div class="wt-r2" role="toolbar" aria-label="Reading tools">' + tools + '</div>'
454
+ if (tools) h += '<div class="wt-r2" role="toolbar" aria-label="Which changes and reading tools">' + tools + '</div>'
377
455
  // which model wrote it (Settings → Models → Walk me through it picks it)
378
456
  // the session's own agent couldn't (a Codex session, no transcript here, it didn't answer): said quietly, the reason in the tip
379
457
  if (ok) h += '<p class="faint wt-by">Written by ' + (data.modelLabel ? esc(data.modelLabel) : 'your summarizer') + (data.unavailable ? ' (Claude unavailable: ' + esc(data.unavailable) + ')' : '') + (when ? ' · ' + esc(when) : '') +
@@ -394,6 +472,8 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
394
472
  // a small local model (under 7B) is the likely reason: one quiet line pointing at a better one
395
473
  if (data.status === 'fallback' && data.small) h += '<p class="faint wt-small">' + icon('info') + '<span>Small local models struggle here. Try Claude or qwen2.5-coder 7B in <a href="#/settings/models">Settings</a>.</span></p>'
396
474
  }
475
+ // 2026-11-03 switched to the other scope with nothing written for it: never a model run on its own; Write it is one tap
476
+ if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
397
477
  const deck = ok && view === 'story' && ui.deck !== false && (data.steps || []).length > 0 // 0.9.18 the slideshow
398
478
  if (ok && view === 'story' && !deck) {
399
479
  const steps = data.steps || []
@@ -418,7 +498,8 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
418
498
  const vis = new Set(visibleSteps(steps, ui.filter).map((x) => x.id))
419
499
  const core = steps.filter((x) => x.tag === 'Core' && vis.has(x.id)), sup = steps.filter((x) => x.tag !== 'Core' && vis.has(x.id))
420
500
  const bd = data.stats.breakdown || {}
421
- const one = (st) => stepHtml(st, { esc, icon, done: reviewed.has(st.id), cur: ui.cur === st.id, open: ui.open && ui.open.has(st.id), sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data) })
501
+ const shared = sharedSnips(data), skip = skipFiles(data), stat = statusMap(data)
502
+ const one = (st) => stepHtml(st, { esc, icon, done: reviewed.has(st.id), cur: ui.cur === st.id, open: ui.open && ui.open.has(st.id), sid, sf: ui.sf || {}, skip, stat, shared, data })
422
503
  const repos = walkRepos(data)
423
504
  // 2026-10-29 several repos: one section per repo (its header with counts), its steps in reading order
424
505
  if (repos.length) {
@@ -444,6 +525,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
444
525
  }
445
526
  let fl = ''
446
527
  if (!(ok && view === 'story') && data && Array.isArray(data.files) && data.files.length && !ui.loading) {
528
+ if (ok || !data.message) body += unreadableHtml(data, { esc, icon }) // 2026-11-03 what the scope left out, or the no-snapshot note
447
529
  if (data.files.length > 1) { fl = filesListHtml(data, { esc, icon }); body += filesListHtml(data, { esc, icon, mobile: true }) }
448
530
  body += filesHtml(data.files, { esc, icon })
449
531
  }
@@ -474,6 +556,7 @@ export function deckSlides(data, ui = {}) {
474
556
  function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
475
557
  const { slides, cur } = deckSlides(data, ui)
476
558
  const steps = data.steps || []
559
+ const shared = sharedSnips(data)
477
560
  const total = slides.length
478
561
  const sec = (i, inner, label, extra = '') => '<section class="wt-slide' + (i === cur ? ' on' : '') + '" data-slide="' + i + '"' + extra + ' role="group" aria-roledescription="slide" aria-label="' + esc(label) + '"' + (i === cur ? '' : ' hidden') + '>' + inner + '</section>'
479
562
  let h = '<div class="wt-deck" id="wtDeck" data-cur="' + cur + '" data-n="' + total + '" tabindex="-1" aria-roledescription="slideshow" aria-label="Walkthrough slides">'
@@ -504,7 +587,7 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
504
587
  slides.forEach((s, i) => {
505
588
  if (s.kind === 'step') {
506
589
  const x = s.step
507
- h += sec(i, stepHtml(x, { esc, icon, done: reviewed.has(x.id), cur: false, open: true, sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data) }), 'Step ' + x.n + ' of ' + steps.length + ': ' + mdPlain(x.title), ' data-step="' + esc(x.id) + '"')
590
+ h += sec(i, stepHtml(x, { esc, icon, done: reviewed.has(x.id), cur: false, open: true, sid, sf: ui.sf || {}, skip: skipFiles(data), stat: statusMap(data), shared, foldShared: false /* a slide shows one step: the earlier one isn't on screen */, data }), 'Step ' + x.n + ' of ' + steps.length + ': ' + mdPlain(x.title), ' data-step="' + esc(x.id) + '"')
508
591
  } else if (s.kind === 'skip') {
509
592
  const groups = data.groups || []
510
593
  const n = groups.reduce((a, g) => a + g.files.length + (g.kind === 'repeated' ? 1 : 0), 0), l = groups.reduce((a, g) => a + g.lines, 0)
@@ -557,18 +640,63 @@ export function filesHtml(files, { esc, icon }) {
557
640
  const open = files.length <= 10
558
641
  return '<div class="wt-files-v">' + files.map((f, i) => {
559
642
  const what = f.hidden ? 'not shown (keys and env files never are)' : f.binary ? 'binary file' : f.generated ? 'generated or lockfile · ' + plural(f.add + f.del, 'line') : !f.lines ? (f.status === 'renamed' ? 'renamed, nothing changed inside' : 'no lines changed') : ''
560
- return '<details class="wt-file" id="wt-f-' + i + '" data-file-i="' + i + '"' + (open && !what ? ' open' : '') + '><summary>' + icon('chevron-right', 'ic wt-g-chev') + statusHtml(f, { esc, icon }) + '<span class="mono wt-file-p">' + esc(f.repo && String(f.path).startsWith(f.repo + '/') ? f.repo + ' · ' + f.path.slice(f.repo.length + 1) : f.path) + '</span>' + (f.oldPath ? '<span class="faint wt-file-o">from ' + esc(f.oldPath) + '</span>' : '') +
643
+ return '<details class="wt-file' + (f.notSession ? ' nots' : '') + '" id="wt-f-' + i + '" data-file-i="' + i + '"' + (open && !what ? ' open' : '') + '><summary>' + icon('chevron-right', 'ic wt-g-chev') + statusHtml(f, { esc, icon }) + '<span class="mono wt-file-p">' + esc(f.repo && String(f.path).startsWith(f.repo + '/') ? f.repo + ' · ' + f.path.slice(f.repo.length + 1) : f.path) + '</span>' + (f.oldPath ? '<span class="faint wt-file-o">from ' + esc(f.oldPath) + '</span>' : '') + scopeTagHtml(f, { esc }) +
561
644
  '<span class="wt-file-n"><span class="wt-add">+' + n0(f.add) + '</span> <span class="wt-del">−' + n0(f.del) + '</span></span></summary>' +
562
645
  (what ? '<p class="faint wt-g-p">' + esc(what) + '</p>' : '<div class="wt-snip-d" data-file="' + i + '"></div>' + (f.clipped ? '<p class="faint wt-g-p">… too big to show all of it here</p>' : '')) + '</details>'
563
646
  }).join('') + '</div>'
564
647
  }
648
+ // 2026-11-03 (asked: "should Files/Ask be scoped too? Or should it be a toggle?") Which files the
649
+ // walkthrough, its Files view, What it did and Ask cover: This session (the default, only what the
650
+ // session changed) or Whole folder (everything uncommitted, the rest marked "not from this
651
+ // session"). The daemon cuts both the same way (Engine#scopeDiff); the choice is kept on this device.
652
+ export const FILE_SCOPE_KEY = 'lo-file-scope'
653
+ export function fileScopeStore(storage) {
654
+ return {
655
+ get() { try { return storage && storage.getItem(FILE_SCOPE_KEY) === 'folder' ? 'folder' : 'session' } catch { return 'session' } },
656
+ set(v) { try { if (!storage) return; if (v === 'folder') storage.setItem(FILE_SCOPE_KEY, 'folder'); else storage.removeItem(FILE_SCOPE_KEY) } catch {} },
657
+ }
658
+ }
659
+ // "This session 4 · Whole folder 24": a segmented toggle with each side's count. fs: the daemon's
660
+ // { key, session, folder } (none from an older Mac, or with the snapshot rule off: no toggle).
661
+ export function fileScopeSegHtml(fs, { esc, icon, action = 'wt-files', busy = false } = {}) {
662
+ if (!fs || typeof fs !== 'object' || fs.session == null || fs.folder == null) return ''
663
+ const key = fs.key === 'folder' ? 'folder' : 'session'
664
+ const n = (v) => '<span class="fsc-n" aria-hidden="true">' + esc(Number(v) || 0) + '</span>'
665
+ const one = (k, label, cnt, tip) => '<button type="button" data-action="' + esc(action) + '" data-files="' + k + '" aria-pressed="' + (key === k) + '" aria-label="' + esc(label + ', ' + (Number(cnt) || 0) + ' file' + (Number(cnt) === 1 ? '' : 's')) + '" data-tip="' + esc(tip) + '"' + (busy ? ' disabled' : '') + '>' + label + ' ' + n(cnt) + '</button>'
666
+ return '<div class="wt-seg wt-seg-sm fsc-seg" role="group" aria-label="Which changes">' +
667
+ one('session', 'This session', fs.session, 'Only what this session changed') +
668
+ one('folder', 'Whole folder', fs.folder, 'Everything uncommitted in the folder, other sessions’ changes too') + '</div>'
669
+ }
670
+ // The context line Ask puts above a question: "This session’s changes (4 files)" / "Whole folder (24 files)"
671
+ export function scopeAskLine(data) {
672
+ const fs = data && data.fileScope
673
+ if (!fs || fs.session == null || fs.folder == null) return ''
674
+ const folder = fs.key === 'folder'
675
+ const k = Number(folder ? fs.folder : fs.session) || 0
676
+ const files = k + ' file' + (k === 1 ? '' : 's')
677
+ return folder ? 'Whole folder (' + files + ')' : 'This session’s changes (' + files + ')'
678
+ }
679
+ // the quiet per-file words: not from this session (Whole folder), or changed before it too
680
+ export function scopeTagWords(f) {
681
+ if (!f) return ''
682
+ if (f.notSession) return 'not from this session'
683
+ if (f.preDirty === 'start') return 'only this session’s change'
684
+ if (f.preDirty === 'whole') return 'changed before this session too'
685
+ if (f.preDirty === 'before') return 'has changes from before this session'
686
+ return ''
687
+ }
688
+ export function scopeTagHtml(f, { esc }) {
689
+ const w = scopeTagWords(f)
690
+ if (!w) return ''
691
+ return f.notSession ? '<span class="wt-nots" data-tip="Changed in this folder, but not by this session"><i class="nots-g" aria-hidden="true"></i>not from this session</span>' : '<span class="wt-pre faint">' + esc(w) + '</span>'
692
+ }
565
693
  // 0.9.18 (Joel): the Files view's list of files, beside the diff on a wide screen (each
566
694
  // scrolls on its own) and folded above it on a phone. Folders like the file map;
567
695
  // each file with its status by shape and its +/− count; a click opens that file's diff.
568
696
  function flFileHtml(it, { esc, icon, depth }) {
569
697
  const f = it.f
570
- return '<a class="wt-fm-i wt-fl-i" href="#" data-action="wt-file" data-i="' + it.i + '"' + (depth ? ' data-d="' + Math.min(depth, 6) + '"' : '') + ' aria-label="' + esc(f.path + ', ' + statusWord(f).toLowerCase() + ', ' + f.add + ' added, ' + f.del + ' removed') + '">' +
571
- statusHtml(f, { esc, icon }) + '<span class="wt-fm-f">' + esc(it.base) + '</span><span class="wt-fl-n" aria-hidden="true"><span class="wt-add">+' + n0(f.add) + '</span><span class="wt-del">−' + n0(f.del) + '</span></span></a>'
698
+ return '<a class="wt-fm-i wt-fl-i' + (f.notSession ? ' nots' : '') + '" href="#" data-action="wt-file" data-i="' + it.i + '"' + (depth ? ' data-d="' + Math.min(depth, 6) + '"' : '') + ' aria-label="' + esc(f.path + ', ' + statusWord(f).toLowerCase() + ', ' + f.add + ' added, ' + f.del + ' removed' + (scopeTagWords(f) ? ', ' + scopeTagWords(f) : '')) + '"' + (f.notSession ? ' data-tip="Not from this session"' : '') + '>' +
699
+ statusHtml(f, { esc, icon }) + '<span class="wt-fm-f">' + esc(it.base) + '</span>' + (f.notSession ? '<i class="nots-g" aria-hidden="true"></i>' : '') + '<span class="wt-fl-n" aria-hidden="true"><span class="wt-add">+' + n0(f.add) + '</span><span class="wt-del">−' + n0(f.del) + '</span></span></a>'
572
700
  }
573
701
  export function filesListHtml(data, { esc, icon, mobile = false }) {
574
702
  const files = (data && data.files) || []
@@ -597,7 +725,7 @@ export function hydrateWalkthrough(root, data, { renderDiff, renderSplit = null,
597
725
  if (!cache) { cache = new Map(); drawnFor.set(data, cache) }
598
726
  const doc = root.ownerDocument
599
727
  const sp = !!(split && renderSplit)
600
- const draw = (key, patch, label, callouts) => {
728
+ const draw = (key, patch, label, callouts, focus = null) => {
601
729
  const ck = (sp ? 'S|' : 'U|') + key
602
730
  let node = cache.get(ck)
603
731
  if (!node) {
@@ -609,6 +737,15 @@ export function hydrateWalkthrough(root, data, { renderDiff, renderSplit = null,
609
737
  node.classList.add('wt-gd')
610
738
  node.dataset.wtk = key
611
739
  for (const g of node.querySelectorAll('.gd-gap')) g.remove()
740
+ // 2026-11-03 the lines this step's refs name, when it shows a hunk whole (a new file two steps share)
741
+ for (const f of Array.isArray(focus) ? focus : []) {
742
+ const sel = f.side === 'old' ? '.gd-o' : '.gd-n'
743
+ for (const el of node.querySelectorAll(sp ? '.gd-half' : '.gd-row')) {
744
+ const x = el.querySelector(sel), v = x && x.textContent !== '' ? Number(x.textContent) : NaN
745
+ if (v >= f.from && v <= f.to) el.classList.add('wt-focus')
746
+ }
747
+ }
748
+ if (Array.isArray(focus) && focus.length) node.classList.add('wt-gd-focus')
612
749
  for (const c of callouts || []) {
613
750
  const sel = c.side === 'old' ? '.gd-o' : '.gd-n'
614
751
  let row = null
@@ -640,8 +777,10 @@ export function hydrateWalkthrough(root, data, { renderDiff, renderSplit = null,
640
777
  const [sid, i] = slot.getAttribute('data-snip').split(':')
641
778
  const st = (data.steps || []).find((x) => x.id === sid)
642
779
  const sn = st && st.snippets[+i]
643
- if (!sn) continue
644
- slot.appendChild(draw('s:' + sid + ':' + i, sn.patch, 'Changes in ' + sn.file, st.callouts.filter((c) => c.hunk === sn.hunk))); k++
780
+ if (!sn) { slot.innerHTML = '<p class="faint wt-snip-none">No lines to show here.</p>'; continue } // never an empty row
781
+ const node = draw('s:' + sid + ':' + i, sn.patch, 'Changes in ' + sn.file, st.callouts.filter((c) => c.hunk === sn.hunk), sn.focus)
782
+ if (node.isConnected && root.contains(node) && !slot.contains(node)) { slot.appendChild(node.cloneNode(true)); k++; continue } // shown in another slot on this page: a copy, never moved out of it
783
+ slot.appendChild(node); k++
645
784
  }
646
785
  for (const slot of root.querySelectorAll('[data-group-ex]')) {
647
786
  if (slot.firstChild) continue
@@ -691,6 +830,7 @@ export function toMarkdown(data, { title = '' } = {}) {
691
830
  if (repos.length && st.repo && st.repo !== lastRepo) { lastRepo = st.repo; const r = repos.find((x) => x.label === st.repo); L.push(''); L.push('### ' + st.repo + (r ? ' (' + repoHeadWords(r, data.steps || []) + ')' : '')) }
692
831
  L.push(''); L.push((repos.length ? '#### ' : '### ') + st.n + '. ' + st.title + ' (' + st.tag + ')'); L.push('')
693
832
  if (st.why) L.push(st.why)
833
+ if (Array.isArray(st.decisions) && st.decisions.length) { const by = decisionsOf(data); L.push(''); L.push('Decisions: ' + st.decisions.map((id) => decWord(st, id, by.get(id)) + '`' + id + '`' + (by.get(id) ? ' ' + by.get(id).text : '')).join('; ')) } // 2026-11-03
694
834
  for (const c of st.callouts || []) { L.push(''); L.push('> **' + (CALLOUT[c.kind] || CALLOUT.look).word + '** (`' + pathLabel(c.file, data) + ':' + c.line + '`): ' + c.text) }
695
835
  for (const sn of st.snippets || []) {
696
836
  const fence = /```/.test(sn.patch) ? '~~~~' : '```'
@@ -703,6 +843,7 @@ export function toMarkdown(data, { title = '' } = {}) {
703
843
  for (const g of groups) L.push('- ' + g.title + (g.note ? ' (' + g.note + ')' : '') + ': ' + plural(g.files.length + (g.kind === 'repeated' ? 1 : 0), 'file') + ', ' + plural(g.lines, 'line'))
704
844
  }
705
845
  if (data.coverage) { L.push(''); L.push('_Every changed line is in a step or a group: ' + n0(data.coverage.covered) + ' of ' + n0(data.coverage.total) + '._') }
846
+ if (Array.isArray(data.decisions) && data.decisions.length) { L.push(''); L.push('### Decisions cited'); L.push(''); for (const d of data.decisions) L.push('- `' + d.id + '` ' + d.text + (d.why ? ' (' + d.why + ')' : '')) }
706
847
  return L.join('\n') + '\n'
707
848
  }
708
849
 
@@ -721,7 +862,7 @@ export function staticDiffHtml(patch, callouts = []) {
721
862
  }
722
863
  return h + '</div>'
723
864
  }
724
- const EXPORT_CSS = 'body{margin:0;background:#fff;color:#1f2328;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}main{max-width:900px;margin:0 auto;padding:32px 20px 64px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:32px 0 6px}.f{color:#59636e;font-size:13px}.sum{font-size:15px}.tag{display:inline-flex;align-items:center;height:20px;padding:0 8px;border:1px solid #d1d9e0;border-radius:999px;font-size:12px;line-height:1;margin-left:8px;vertical-align:2px}.tag.core{font-weight:700;border-width:2px}.fl{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:#59636e;margin:10px 0 4px}.d{border:1px solid #d1d9e0;border-radius:6px;overflow:hidden;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.r{display:grid;grid-template-columns:44px 44px 18px 1fr}.g{color:#59636e;text-align:right;padding:0 6px;user-select:none}.m{text-align:center}.c{white-space:pre-wrap;word-break:break-word;padding-right:8px}.r.h{background:#ddf4ff;color:#59636e}.r.add{background:#e6ffec}.r.add .g{background:#ccffd8}.r.del{background:#ffebe9}.r.del .g{background:#ffd7d5}.r.mark .g:first-child{box-shadow:inset 3px 0 0 #1f2328}.co{margin:4px 8px 6px 106px;padding:6px 10px;border:1px solid #1f2328;border-radius:6px;font:13px/1.45 -apple-system,system-ui,sans-serif;white-space:normal}.co.assumption{border-style:dotted}.co.untested{border-style:dashed}.co b{margin-right:6px}.loose .co{margin-left:0}details{margin:8px 0}summary{cursor:pointer}ul{padding-left:20px}code{font:.86em ui-monospace,SFMono-Regular,Menlo,monospace;background:#f6f8fa;border:1px solid #d1d9e0;border-radius:5px;padding:0 4px;overflow-wrap:break-word;word-break:normal;-webkit-box-decoration-break:clone;box-decoration-break:clone}h2 code{font-size:.84em}.why{margin:8px 0 12px;overflow-wrap:break-word}.why p{margin:0 0 8px}.why :is(ul,ol){margin:0 0 8px;padding-left:20px}.why li{margin:2px 0}.why h2,.why h3,.why h4,.why h5,.why h6{font-size:1em;margin:10px 0 4px}.why blockquote{margin:0 0 8px;padding-left:10px;border-left:3px solid #d1d9e0}.why pre{margin:0 0 8px;overflow-x:auto;background:#f6f8fa;border:1px solid #d1d9e0;border-radius:6px;padding:8px 10px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}.why pre code{background:none;border:0;padding:0;font-size:inherit;overflow-wrap:normal;word-break:normal}.co code{font-size:.84em}a{color:#0969da;overflow-wrap:anywhere}.u{overflow-wrap:anywhere}.tk-c{color:#59636e}.tk-s{color:#0a3069}.tk-k{color:#cf222e}.tk-n{color:#0550ae}.tk-add{color:#116329}.tk-del{color:#82071e}@media print{.why pre{white-space:pre-wrap}code{background:none}}@media (prefers-color-scheme:dark){body{background:#0d1117;color:#e6edf3}code,.why pre{background:#161b22;border-color:#3d444d}.why blockquote{border-color:#3d444d}a{color:#4493f8}.tk-c{color:#9198a1}.tk-s{color:#a5d6ff}.tk-k{color:#ff7b72}.tk-n{color:#79c0ff}.tk-add{color:#7ee787}.tk-del{color:#ffa198}.f,.g,.fl{color:#9198a1}.d,.tag{border-color:#3d444d}.r.h{background:#121d2f}.r.add{background:#12261e}.r.add .g{background:#1b4721}.r.del{background:#25171c}.r.del .g{background:#542426}.co,.r.mark .g:first-child{border-color:#e6edf3;box-shadow:inset 3px 0 0 #e6edf3}}'
865
+ export const EXPORT_CSS = 'body{margin:0;background:#fff;color:#1f2328;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}main{max-width:900px;margin:0 auto;padding:32px 20px 64px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:32px 0 6px}.f{color:#59636e;font-size:13px}.sum{font-size:15px}.tag{display:inline-flex;align-items:center;height:20px;padding:0 8px;border:1px solid #d1d9e0;border-radius:999px;font-size:12px;line-height:1;margin-left:8px;vertical-align:2px}.tag.core{font-weight:700;border-width:2px}.fl{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:#59636e;margin:10px 0 4px}.d{border:1px solid #d1d9e0;border-radius:6px;overflow:hidden;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.r{display:grid;grid-template-columns:44px 44px 18px 1fr}.g{color:#59636e;text-align:right;padding:0 6px;user-select:none}.m{text-align:center}.c{white-space:pre-wrap;word-break:break-word;padding-right:8px}.r.h{background:#ddf4ff;color:#59636e}.r.add{background:#e6ffec}.r.add .g{background:#ccffd8}.r.del{background:#ffebe9}.r.del .g{background:#ffd7d5}.r.mark .g:first-child{box-shadow:inset 3px 0 0 #1f2328}.co{margin:4px 8px 6px 106px;padding:6px 10px;border:1px solid #1f2328;border-radius:6px;font:13px/1.45 -apple-system,system-ui,sans-serif;white-space:normal}.co.assumption{border-style:dotted}.co.untested{border-style:dashed}.co b{margin-right:6px}.loose .co{margin-left:0}details{margin:8px 0}summary{cursor:pointer}ul{padding-left:20px}code{font:.86em ui-monospace,SFMono-Regular,Menlo,monospace;background:#f6f8fa;border:1px solid #d1d9e0;border-radius:5px;padding:0 4px;overflow-wrap:break-word;word-break:normal;-webkit-box-decoration-break:clone;box-decoration-break:clone}h2 code{font-size:.84em}.why{margin:8px 0 12px;overflow-wrap:break-word}.why p{margin:0 0 8px}.why :is(ul,ol){margin:0 0 8px;padding-left:20px}.why li{margin:2px 0}.why h2,.why h3,.why h4,.why h5,.why h6{font-size:1em;margin:10px 0 4px}.why blockquote{margin:0 0 8px;padding-left:10px;border-left:3px solid #d1d9e0}.why pre{margin:0 0 8px;overflow-x:auto;background:#f6f8fa;border:1px solid #d1d9e0;border-radius:6px;padding:8px 10px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}.why pre code{background:none;border:0;padding:0;font-size:inherit;overflow-wrap:normal;word-break:normal}.co code{font-size:.84em}a{color:#0969da;overflow-wrap:anywhere}.u{overflow-wrap:anywhere}.tk-c{color:#59636e}.tk-s{color:#0a3069}.tk-k{color:#cf222e}.tk-n{color:#0550ae}.tk-add{color:#116329}.tk-del{color:#82071e}@media print{.why pre{white-space:pre-wrap}code{background:none}}@media (prefers-color-scheme:dark){body{background:#0d1117;color:#e6edf3}code,.why pre{background:#161b22;border-color:#3d444d}.why blockquote{border-color:#3d444d}a{color:#4493f8}.tk-c{color:#9198a1}.tk-s{color:#a5d6ff}.tk-k{color:#ff7b72}.tk-n{color:#79c0ff}.tk-add{color:#7ee787}.tk-del{color:#ffa198}.f,.g,.fl{color:#9198a1}.d,.tag{border-color:#3d444d}.r.h{background:#121d2f}.r.add{background:#12261e}.r.add .g{background:#1b4721}.r.del{background:#25171c}.r.del .g{background:#542426}.co,.r.mark .g:first-child{border-color:#e6edf3;box-shadow:inset 3px 0 0 #e6edf3}}'
725
866
  // ONE self-contained file: inline CSS, the snippets baked in, no scripts, no links out.
726
867
  // 0.9.18 a deck, like the board's: an overview, then one slide per step, then Safe to
727
868
  // skip. Without a script: one radio per slide (hidden, still focusable, so ← / → move
@@ -748,6 +889,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
748
889
  const agHead = (s) => { if (!repos.length || !s.st || !s.st.repo || s.st.repo === agRepo) return ''; agRepo = s.st.repo; const r = repos.find((x) => x.label === s.st.repo); return '<li class="rh"><h3>' + escH(s.st.repo) + (r ? ' <span class="f">· ' + repoHeadWords(r, steps) + '</span>' : '') + '</h3></li>' }
749
890
  o += '<h2>Reading order</h2><ol class="ag">' + slides.map((s, i) => i === 0 ? '' : agHead(s) + '<li><label for="s' + i + '">' + (s.st ? s.st.n + '. ' : '') + walkInline(s.t) + (s.st ? ' <span class="tag ' + (s.st.tag === 'Core' ? 'core' : '') + '">' + escH(s.st.tag) + '</span>' : '') + '</label></li>').join('') + '</ol>'
750
891
  if (data.coverage) o += '<p class="f">Every changed line is in a step or a group: ' + n0(data.coverage.covered) + ' of ' + n0(data.coverage.total) + '.</p>'
892
+ if (Array.isArray(data.decisions) && data.decisions.length) o += '<h2>Decisions cited</h2><ul>' + data.decisions.map((d) => '<li><code>' + escH(d.id) + '</code> ' + escH(d.text) + (d.why ? ' <span class="f">(' + escH(d.why) + ')</span>' : '') + '</li>').join('') + '</ul>' // 2026-11-03
751
893
  b += '<section class="sl" data-i="0">' + o + nav(0) + '</section>'
752
894
  slides.forEach((s, i) => {
753
895
  if (!i) return
@@ -756,6 +898,7 @@ export function toHtmlDoc(data, { title = '', repo = '', when = '' } = {}) {
756
898
  const st = s.st
757
899
  if (repos.length && st.repo) { const r = repos.find((y) => y.label === st.repo); x += '<h3 class="rp">' + escH(st.repo) + (r ? ' <span class="f">· ' + repoHeadWords(r, steps) + '</span>' : '') + '</h3>' } // 2026-10-29 the step's repo
758
900
  x += '<h2>' + st.n + '. ' + walkInline(st.title) + '<span class="tag ' + (st.tag === 'Core' ? 'core' : '') + '">' + escH(st.tag) + '</span></h2>' + (st.why ? '<div class="why">' + walkBlock(st.why, { exported: true }) + '</div>' : '')
901
+ if (Array.isArray(st.decisions) && st.decisions.length) { const by = decisionsOf(data); x += '<p class="f decs">Decisions: ' + st.decisions.map((id) => escH(decWord(st, id, by.get(id))) + '<code>' + escH(id) + '</code>' + (by.get(id) ? ' ' + escH(by.get(id).text) : '')).join('; ') + '</p>' } // 2026-11-03
759
902
  const drawn = new Set()
760
903
  for (const sn of st.snippets || []) {
761
904
  const cs = (st.callouts || []).filter((c) => c.hunk === sn.hunk)