@leftoff/cli 0.9.17 → 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/CHANGELOG.md +56 -2
- package/bin/leftoff-statusline.js +9 -0
- package/bin/leftoff.js +794 -540
- package/bin/pty-host-main.js +66 -0
- package/docs/macos-permissions.md +52 -0
- package/docs/test-plan.md +43 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +172 -12
- package/mod/hooks/register.js +186 -5
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +240 -2
- package/ui/app.js +553 -62
- package/ui/ask.js +6 -3
- package/ui/cp-fold.js +22 -0
- package/ui/decisions.js +235 -0
- package/ui/hook-fix.js +109 -0
- package/ui/live-status.js +68 -2
- package/ui/live-view.js +23 -6
- package/ui/prs.js +15 -5
- package/ui/semdiff.js +165 -22
- package/ui/session-catchup.js +18 -2
- package/ui/session-title.js +88 -0
- package/ui/start.js +87 -12
- package/ui/summary-examples.js +2 -2
- package/ui/term.js +11 -3
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +59 -0
- package/ui/walk-pop.js +3 -0
- package/ui/what-it-did.js +12 -5
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 }
|
|
@@ -20,7 +21,7 @@ export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.en
|
|
|
20
21
|
// a thread while it is live-linked and the how-to otherwise.
|
|
21
22
|
// -> 'none' | 'thread' | 'readonly' (a stopped headless one: no composer) | 'how'
|
|
22
23
|
export function liveBoxKind(s) {
|
|
23
|
-
if (!s || s.agent || s.codingAgent === 'codex') return 'none'
|
|
24
|
+
if (!s || s.agent || (s.codingAgent === 'codex' && !s.headless)) return 'none' // 1.0: headless Codex has Live
|
|
24
25
|
if (s.capture === 'hooks' && !s.headless) return 'none'
|
|
25
26
|
if (s.liveLink && s.liveLink.on) return 'thread'
|
|
26
27
|
if (s.headless) return 'thread' // 0.9.13: not running (Paused, Stopped): a message resumes it
|
|
@@ -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
|
-
|
|
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
|
|
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)) || ''
|
|
@@ -123,7 +139,7 @@ const LIVE_TIP = 'Live link: send this session messages from the board or your p
|
|
|
123
139
|
export function liveBadgeHtml(s, now = Date.now()) {
|
|
124
140
|
if (!cardPills(s).live) return ''
|
|
125
141
|
const w = liveWorking(s, now)
|
|
126
|
-
return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? 'Claude is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
|
|
142
|
+
return ' <span class="pill live-pill' + (w ? ' working' : '') + '" title="' + (w ? (s.codingAgent === 'codex' ? 'Codex' : 'Claude') + ' is working on a turn · ' : '') + LIVE_TIP + '">Live</span>'
|
|
127
143
|
}
|
|
128
144
|
|
|
129
145
|
// The board redraws a card (innerHTML) on every update, which would restart a
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -503,7 +520,7 @@ export function pendingClaudeHtml(you, { esc, ago, now = Date.now(), lvActs = nu
|
|
|
503
520
|
if (st.phase === 'stopped') inner = (st.why && st.why.label ? whyHtml(st.why, { esc }) : '<div class="lv-text lv-none">' + esc(st.lost ? 'No reply recorded' : 'Stopped without a reply') + '</div>') + acts + toolsHtml(st, { esc, now })
|
|
504
521
|
else if (st.phase === 'replied') inner = '<div class="lv-text lv-none">Replied in the terminal</div>' + toolsHtml(st, { esc, now }) // settled, its words not kept: never a Working bubble
|
|
505
522
|
else inner = workingHtml(st, { esc, now })
|
|
506
|
-
return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st) + (acts ? '|a' + actsSig(lvActs) : ''))) + '"><div class="lv-who">Claude ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
|
|
523
|
+
return '<li class="lv lv-claude lv-pending' + (FINAL_PHASES.has(st.phase) ? ' lv-ended' : '') + '" data-key="' + esc(key) + '" data-pending="1" data-sig="' + esc(sigHash('pending' + statusSig(st) + (acts ? '|a' + actsSig(lvActs) : ''))) + '"><div class="lv-who">' + esc(you.answerBy || 'Claude') + ' ' + timeHtml(st.last ? new Date(st.last).toISOString() : you.at, { esc, now }) + '</div>' + inner + '</li>'
|
|
507
524
|
}
|
|
508
525
|
|
|
509
526
|
// The whole thread: each message you sent, then Claude's side of that turn.
|
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) + '"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
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,13 +448,14 @@ 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
|
-
|
|
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="
|
|
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
|
-
if (ok) h += '<p class="faint wt-by">Written by ' + (data.modelLabel ? esc(data.modelLabel) : 'your summarizer') + (when ? ' · ' + esc(when) : '') +
|
|
380
|
-
(data.path === 'fallback' ? ' · <span class="wt-path" tabindex="0" data-tip="' + esc('Not the session’s agent: ' + (data.pathWhy || 'it couldn’t be forked here')) + '">not the session’s agent</span>' : '') + '</p>'
|
|
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) : '') +
|
|
458
|
+
(data.path === 'fallback' && !data.unavailable ? ' · <span class="wt-path" tabindex="0" data-tip="' + esc('Not the session’s agent: ' + (data.pathWhy || 'it couldn’t be forked here')) + '">not the session’s agent</span>' : '') + '</p>'
|
|
381
459
|
// 2026-10-30 a kept walkthrough for a diff that has moved on: said so, with Update (never shown as current)
|
|
382
460
|
if (ok && data.stale) h += staleBarHtml(data.stale, { esc, icon, sid, loading: ui.loading })
|
|
383
461
|
// nothing new since the last checkpoint: the whole session instead, and a quiet word saying so
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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)
|
package/ui/session-catchup.js
CHANGED
|
@@ -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…" /
|