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

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/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/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)
@@ -29,17 +29,33 @@ export function sessionCatchupHtml(ui, { esc, icon = () => '', sid = '' } = {})
29
29
  : '<ul class="scu-body scu-list">' + body.map((_, i) => '<li data-scu="b-' + i + '"></li>').join('') + '</ul>'
30
30
  return '<div class="scu" role="region" aria-label="Catch me up">' + head('<span class="scu-when" data-scu="when"></span>') +
31
31
  (goal ? '<p class="scu-goal" data-scu="goal"></p>' : '') +
32
- text + (next != null ? '<p class="scu-next" data-scu="next"></p>' : '') +
32
+ text + (next != null ? '<p class="scu-next" data-scu="next"></p>' : '') + recapDecisionsHtml(ui.data, { esc }) +
33
33
  '<div class="msg-foot"><p class="scu-by" data-scu="by"></p>' + copyButtonHtml(recapSource(ui.data), { deferred: true }) + '</div></div>'
34
34
  }
35
35
 
36
+ // testplan Part 2: what the session decided since you looked, each with its word in the log (shape plus word).
37
+ const DEC_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻', unlogged: '·' }
38
+ const DEC_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded', unlogged: 'Not in the log' }
39
+ export function recapDecisionsHtml(d, { esc }) {
40
+ const list = (d && Array.isArray(d.decisions) ? d.decisions : []).filter((x) => x && x.text)
41
+ if (!list.length) return ''
42
+ const sinceWord = d && d.recap && d.recap.since ? 'Decided since you looked' : 'Decided in this session'
43
+ return '<div class="scu-decs"><h3 class="scu-dec-h">' + esc(sinceWord) + '</h3><ul class="scu-dec-l">' + list.map((x) =>
44
+ '<li><span class="dec-st dec-st-' + esc(x.status) + '"><span aria-hidden="true">' + (DEC_SHAPE[x.status] || '·') + '</span> ' + esc(DEC_WORD[x.status] || x.status) + '</span> ' +
45
+ (x.id ? '<span class="dec-id">' + esc(x.id) + '</span> ' : '') + '<span class="dec-text">' + esc(x.text) + '</span>' + (x.supersededBy ? ' <span class="faint">→ ' + esc(x.supersededBy) + '</span>' : '') + '</li>').join('') + '</ul></div>'
46
+ }
47
+ export function recapDecisionsText(d) {
48
+ const list = (d && Array.isArray(d.decisions) ? d.decisions : []).filter((x) => x && x.text)
49
+ return list.length ? 'Decided:\n' + list.map((x) => '- ' + (x.id ? x.id + ' ' : '') + x.text + ' (' + (DEC_WORD[x.status] || x.status).toLowerCase() + ')').join('\n') : ''
50
+ }
51
+
36
52
  // 0.9.11: what Copy takes from a recap, as markdown: the goal, the lines as a
37
53
  // list (or a sentence or two, as shown), and the Next line.
38
54
  export function recapSource(d) {
39
55
  const { body, next } = recapParts(d)
40
56
  const goal = recapGoal(d)
41
57
  const text = body.length <= 2 ? body.join(' ') : body.map((b) => '- ' + b).join('\n')
42
- return [goal, text, next].filter((x) => x && String(x).trim()).join('\n\n')
58
+ return [goal, text, next, recapDecisionsText(d)].filter((x) => x && String(x).trim()).join('\n\n')
43
59
  }
44
60
 
45
61
  // The recap's lines: what happened, and the one "Next: …" / "Up to you…" /
@@ -0,0 +1,88 @@
1
+ // 2026-11-05 session titles: one helper for the name a session goes by
2
+ // everywhere (session header, full screen's bar, cards, tabs, pickers).
3
+ // Line one says what the session is doing; where it runs (folder@branch) is a
4
+ // muted second line, never glued to the front of the name. Pure and
5
+ // deterministic (no model call): a prompt used as a name is tidied the way a
6
+ // person would write it as a title.
7
+
8
+ export const TITLE_MAX = 60
9
+
10
+ // "cube_cloud@me/some-branch · Please check…": where it runs, glued to the front
11
+ // of a name by whatever named it. The folder and branch have a line of their own.
12
+ const WHERE_GLUED = /^\s*[\w.~-]+@[\w./~+-]+\s*(?:·|•|\||—|–|:|-)\s+/
13
+ // Words in front of a request that a title doesn't need.
14
+ const FILLER = /^(?:(?:please|pls|plz|hey|hi|hello|yo|ok(?:ay)?|so|alright|now)\b[\s,!.:;-]*|(?:can|could|would|will) (?:you|u)(?: please| pls)?\s+|(?:i(?:'d| would) like you to|i (?:want|need) you to)\s+)/i
15
+ // A title doesn't end on a word that leads into the next one.
16
+ const DANGLING = /[\s,;:–—-]+(?:and|or|but|nor|the|a|an|to|of|for|with|without|in|on|at|from|by|into|onto|about|then|so|that|this|these|those|is|are|was|be|as|if|when|while|because|my|your|our|its|&|\+)$/i
17
+ const URL_RE = /\bhttps?:\/\/[^\s<>()]+/gi
18
+ const LONG_PATH = /(^|\s)(?:~|\.{1,2})?(?:\/[\w.@+-]+){3,}\/?/g
19
+
20
+ const repoOf = (s) => (s && (s.repoLabel || s.repoName || (s.repo && s.repo.name))) || ''
21
+ const branchOf = (s) => (s && (s.branch || (s.repo && s.repo.branch))) || ''
22
+
23
+ // Where a session runs, as one short line: "folder@branch" (the folder alone
24
+ // when the branch says the same or there is none). '' when unknown.
25
+ export function whereLine(s, repo = repoOf(s)) {
26
+ const b = branchOf(s)
27
+ if (!repo) return b || ''
28
+ return b && b !== repo ? repo + '@' + b : repo
29
+ }
30
+
31
+ // A pasted link, as the few words it stands for: a GitHub pull request or issue
32
+ // is "PR 408" / "issue 12", anything else its host.
33
+ function urlWords(u) {
34
+ const m = u.match(/^https?:\/\/(?:www\.)?github\.com\/[^/\s]+\/[^/\s]+\/(pull|issues)\/(\d+)/i)
35
+ if (m) return (m[1].toLowerCase() === 'pull' ? 'PR ' : 'issue ') + m[2]
36
+ try { return new URL(u).host.replace(/^www\./, '') } catch { return '' }
37
+ }
38
+
39
+ // Cut at a word boundary: never mid-word, never on a dangling "and"/"the"/"to".
40
+ // Text with no spaces to cut at (CJK, one long token) is cut by character.
41
+ export function cutTitle(text, max = TITLE_MAX) {
42
+ let t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
43
+ let cut = false
44
+ if (t.length > max) {
45
+ const room = t.slice(0, max)
46
+ const sp = room.lastIndexOf(' ')
47
+ t = /\s/.test(t[max]) ? room : sp > 0 ? room.slice(0, sp) : t.slice(0, max - 1)
48
+ cut = true
49
+ }
50
+ for (let i = 0; i < 4; i++) {
51
+ const before = t
52
+ t = t.replace(DANGLING, '').replace(/[\s,;:(\[{–—-]+$/, '')
53
+ if (t === before) break
54
+ cut = true
55
+ }
56
+ if (!t) return ''
57
+ return cut ? t.replace(/[.…]+$/, '') + '…' : t
58
+ }
59
+
60
+ // A first prompt as a title: its first real line, no greeting or "please",
61
+ // links as what they point at, a capital first letter, cut cleanly.
62
+ // "Please checkout the PR 408 and then…" -> "Check out the PR 408…"
63
+ export function promptTitleText(text, max = TITLE_MAX) {
64
+ let t = String(text == null ? '' : text).split('\n').map((l) => l.trim()).find(Boolean) || ''
65
+ t = t.replace(URL_RE, (u) => ' ' + urlWords(u) + ' ').replace(LONG_PATH, (m, pre) => pre + m.trim().split('/').filter(Boolean).pop())
66
+ t = t.replace(/\s+/g, ' ').trim()
67
+ for (let i = 0; i < 4; i++) { const n = t.replace(FILLER, ''); if (n === t || !n.trim()) break; t = n.trim() }
68
+ t = t.replace(/^checkout\s+(?=the\b|pr\b|#|branch\b)/i, 'Check out ')
69
+ t = t.replace(/^[\s,.:;!?–—-]+/, '')
70
+ t = cutTitle(t, max)
71
+ return t && !/^\S+\.\S/.test(t) ? t.charAt(0).toUpperCase() + t.slice(1) : t // a bare host stays as written
72
+ }
73
+
74
+ // A stored name, tidied for showing: where it runs comes off the front (it has
75
+ // its own line). A name that came from a prompt (or carried a where prefix,
76
+ // which only automatic names do) is tidied like a prompt; a name you or Claude
77
+ // gave it is kept as written, only cut at a word if it is very long.
78
+ export function displayTitle(title, { source = null, max = TITLE_MAX } = {}) {
79
+ let t = String(title == null ? '' : title).replace(/\s+/g, ' ').trim()
80
+ const glued = WHERE_GLUED.test(t) && t.replace(WHERE_GLUED, '').trim()
81
+ if (glued) t = glued
82
+ if (glued || source === 'prompt' || source === 'fallback') return promptTitleText(t, max) || t
83
+ return t.length > 2 * max ? cutTitle(t, 2 * max) : t
84
+ }
85
+
86
+ // Is this stored name only an automatic stand-in (where it runs + the first
87
+ // prompt's words)? Then a generated name may replace it, as for a prompt name.
88
+ export function isPlaceholderTitle(title) { return WHERE_GLUED.test(String(title || '')) }
@@ -40,7 +40,7 @@ export const SUMMARY_EXAMPLES = {
40
40
  }
41
41
  export const LENGTH_CHOICES = [
42
42
  { key: 'short', label: 'Short', approx: '~300 tokens', blurb: 'Doing, Stopped at, Next. Three lines.' },
43
- { key: 'informative', label: 'Informative', approx: '~700 tokens', blurb: 'Adds key decisions, open questions, files touched and why, and what didn’t work.', recommended: true },
43
+ { key: 'informative', label: 'Informative', approx: '~700 tokens', blurb: 'Adds approach notes, open questions, files touched and why, and what didn’t work.', recommended: true },
44
44
  { key: 'long', label: 'Long', approx: '~1500 tokens', blurb: 'Adds a narrative recap and the next 3 steps.' },
45
45
  ]
46
46
 
@@ -56,7 +56,7 @@ export function formatCheckpointText(cp, { indent = '' } = {}) {
56
56
  line(title + ':')
57
57
  items.forEach((x, i) => line((title === 'Next steps' ? ' ' + (i + 1) + '. ' : ' - ') + fmt(x)))
58
58
  }
59
- list('Key decisions', cp.decisions)
59
+ list('Approach notes', cp.decisions)
60
60
  list('Open questions', cp.questions)
61
61
  list('Files touched', cp.files, (f) => (f.path || '') + (f.path && f.why ? ' — ' : '') + (f.why || ''))
62
62
  list('What didn’t work', cp.didntWork)