@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/term.js CHANGED
@@ -171,7 +171,7 @@ async function loadLib() {
171
171
  }
172
172
 
173
173
  export function mountTerminal({ api, icon = () => '', toast = () => {}, context = () => null, openMenu = null, relayApi = () => (typeof window !== 'undefined' && window.leftoffRelay) || null } = {}) {
174
- const st = { max: ls.get(MAX_KEY) === '1', open: false, info: null, tabs: [], active: ls.get(ACTIVE_KEY), views: new Map(), ghostty: undefined, ctrl: false, loading: false, listTimer: 0, sid: null }
174
+ const st = { max: ls.get(MAX_KEY) === '1', open: false, info: null, tabs: [], active: ls.get(ACTIVE_KEY), views: new Map(), ghostty: undefined, ghosttyPeek: undefined, ctrl: false, loading: false, listTimer: 0, sid: null }
175
175
  // 0.9.15 each session's own terminal tab (term-sess.js); st.sid: the session in view, if any
176
176
  let sess = null
177
177
  try { sess = createTermSessStore({ storage: window.localStorage }) } catch { sess = createTermSessStore({ storage: null }) }
@@ -694,6 +694,14 @@ export function mountTerminal({ api, icon = () => '', toast = () => {}, context
694
694
  try { st.ghostty = await api('GET', '/v1/term/theme/ghostty') } catch { st.ghostty = null }
695
695
  return st.ghostty
696
696
  }
697
+ // 1.0: the ⋯ menu only asks whether there is a Ghostty to match; Ghostty's own
698
+ // files (its app data on a Mac) are read when you pick it, not when the menu opens
699
+ async function peekGhostty() {
700
+ if (st.ghostty !== undefined) return st.ghostty
701
+ if (st.ghosttyPeek !== undefined) return st.ghosttyPeek
702
+ try { st.ghosttyPeek = await api('GET', '/v1/term/theme/ghostty?peek=1') } catch { st.ghosttyPeek = null }
703
+ return st.ghosttyPeek
704
+ }
697
705
  applyLook()
698
706
  if ((ls.get(THEME_KEY) || '') === 'ghostty') loadGhostty().then(applyLook)
699
707
  new MutationObserver(() => applyLook()).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
@@ -701,7 +709,7 @@ export function mountTerminal({ api, icon = () => '', toast = () => {}, context
701
709
 
702
710
  // ---------- menu
703
711
  async function menuItems() {
704
- const g = await loadGhostty()
712
+ const g = await peekGhostty()
705
713
  const cur = ls.get(THEME_KEY) || 'auto'
706
714
  const ctx = context() || null
707
715
  const items = []
@@ -713,7 +721,7 @@ export function mountTerminal({ api, icon = () => '', toast = () => {}, context
713
721
  items.push({ label: 'Run claude here', sub: ctx && ctx.sessionId ? 'in ' + (ctx.folder || 'this session’s folder') : 'in ~', icon: icon('terminal'), cls: 'one-line', attrs: { 'data-term-act': 'claude', 'data-tip': 'Runs claude in ' + where } })
714
722
  if (ctx && ctx.sessionId && ctx.resumable) items.push({ label: 'Resume this session', sub: ctx.resumeCmd || 'claude --resume', icon: icon('play'), cls: 'one-line', attrs: { 'data-term-act': 'resume', 'data-tip': (ctx.resumeCmd || 'claude --resume') + ' in ' + where } })
715
723
  items.push({ sep: true })
716
- const choices = THEME_CHOICES.filter((k) => k !== 'ghostty' || (g && g.found && (g.theme || g.light || g.dark)))
724
+ const choices = THEME_CHOICES.filter((k) => k !== 'ghostty' || (g && g.found && (g.theme || g.light || g.dark || g.deferred)))
717
725
  items.push({ label: 'Theme', sub: themeLabel(cur, g), icon: icon('palette'), cls: 'one-line', whole: true, more: { title: 'Theme', items: choices.map((k) => ({ label: themeLabel(k, g), on: k === cur, cls: 'one-line', after: k === cur ? '<span class="lo-act-cur">current</span>' : '', attrs: { 'data-term-theme': k } })) } })
718
726
  return items
719
727
  }
package/ui/testplan.js ADDED
@@ -0,0 +1,162 @@
1
+ // Test plan from merged PRs (testplan Part 3, docs/test-plan.md): the board's page, its Markdown
2
+ // checklist and its downloadable .html (the walkthrough export's look). Pure builders; app.js wires
3
+ // them; unit-tested in test/unit/testplan-*.test.mjs. Words are escaped first; links only to https,
4
+ // the board's own #/ routes, and nothing else.
5
+ import { EXPORT_CSS } from './semdiff.js'
6
+
7
+ const escT = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
8
+ export const BUDGETS = [15, 30, 60]
9
+ export const RISK_SHAPE = { high: '▲', medium: '◆', low: '▽' }
10
+ export const RISK_WORD = { high: 'High risk', medium: 'Medium risk', low: 'Low risk' }
11
+ export const TP_TRUST_TIP = 'Writing a test plan from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.'
12
+ const off = (trusted, esc) => (trusted === false ? ' disabled aria-disabled="true" data-tip="' + esc(TP_TRUST_TIP) + '"' : '')
13
+ const httpsUrl = (u) => (/^https:\/\/[^\s"'<>]+$/.test(String(u || '')) ? String(u) : null)
14
+ const day = (iso) => String(iso || '').slice(0, 10)
15
+
16
+ export const testplanHref = (id, machine = null) => '#/testplan/' + encodeURIComponent(id) + (machine ? '?machine=' + encodeURIComponent(machine) : '')
17
+ export function testplanNewHref({ scope = '', prs = null, since = null } = {}) {
18
+ const q = []
19
+ if (scope) q.push('scope=' + encodeURIComponent(scope))
20
+ if (prs && prs.length) q.push('prs=' + prs.map(encodeURIComponent).join(','))
21
+ if (since) q.push('since=' + encodeURIComponent(since))
22
+ return '#/testplan/new' + (q.length ? '?' + q.join('&') : '')
23
+ }
24
+
25
+ export function progressLine(plan) {
26
+ const cs = (plan && plan.plan && plan.plan.checks) || []
27
+ const t = (plan && plan.ticks) || {}
28
+ return cs.filter((c) => t[c.id] && t[c.id].state).length + ' of ' + cs.length + ' checked'
29
+ }
30
+
31
+ // A citation as a small chip: a PR opens on GitHub, a decision in its log; a step or checkpoint is its words.
32
+ export function citeChipHtml(tag, tags, { esc = escT } = {}) {
33
+ const x = (tags && tags[tag]) || null
34
+ const [kind] = String(tag).split(':')
35
+ const label = kind === 'pr' ? '#' + tag.slice(3) : kind === 'dec' ? tag.slice(4) : kind === 'st' ? 'step' : 'checkpoint'
36
+ const tip = x && x.label ? x.label : tag
37
+ if (kind === 'pr' && x && httpsUrl(x.url)) return '<a class="tp-cite" href="' + esc(x.url) + '" target="_blank" rel="noopener noreferrer" data-tip="' + esc(tip) + '">' + esc(label) + '</a>'
38
+ if (kind === 'dec' && x && x.repo) return '<a class="tp-cite" href="#/decisions/' + esc(x.repo) + '?d=' + esc(encodeURIComponent(tag.slice(4))) + '" data-tip="' + esc(tip) + '">' + esc(label) + '</a>'
39
+ return '<span class="tp-cite" data-tip="' + esc(tip) + '">' + esc(label + (x && x.label ? ': ' + x.label : '')) + '</span>'
40
+ }
41
+
42
+ const riskHtml = (r, esc) => '<span class="tp-risk tp-risk-' + esc(r) + '"><span aria-hidden="true">' + (RISK_SHAPE[r] || '◆') + '</span> ' + esc(RISK_WORD[r] || r) + '</span>'
43
+
44
+ // One check. ticks[c.id]: { state: 'done' | 'failed' | null, note }. proposed: decision id made from it.
45
+ export function checkHtml(c, plan, { esc = escT, trusted = true, noting = null, proposing = null } = {}) {
46
+ const t = (plan.ticks || {})[c.id] || {}
47
+ const st = t.state || null
48
+ const tags = (plan.plan && plan.plan.tags) || {}
49
+ const prop = plan.proposed && plan.proposed[c.id]
50
+ const prRepo = (((plan.prs || []).find((x) => (c.prs || []).includes(x.number)) || (plan.prs || [])[0] || {}).decisionRepo) || ''
51
+ const tick = (v, word, shape) => '<button type="button" class="btn tp-tick tp-tick-' + v + '" data-action="tp-tick" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '" data-state="' + v + '" aria-pressed="' + (st === v) + '"><span aria-hidden="true">' + shape + '</span> ' + word + '</button>'
52
+ const note = st === 'failed' || noting === c.id
53
+ ? '<form class="tp-note" data-action="tp-note" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '"><label class="tp-note-l">What went wrong<input class="dec-in tp-note-in" name="note" maxlength="280" autocomplete="off" value="' + esc(t.note || '') + '" placeholder="One line, for the export"></label><div class="dec-acts"><button type="submit" class="btn">Save note</button>' +
54
+ (prop ? '<a class="linkish tp-prop" href="#/decisions/' + esc(prRepo) + '?d=' + esc(encodeURIComponent(prop)) + '"><span aria-hidden="true">◌</span> Proposed ' + esc(prop) + ' →</a>'
55
+ : '<button type="button" class="btn" data-action="tp-propose" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '"' + off(trusted, esc) + '>Propose a decision</button>') + '</div></form>' +
56
+ (proposing === c.id && !prop ? '<form class="tp-propf dec-edit" data-action="tp-propose-save" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '"><label>The decision (proposed to the repo’s log; you accept it there or not)<input class="dec-in" name="text" maxlength="240" autocomplete="off" value="' + esc(t.note ? 'Correct: ' + t.note : '') + '"></label><div class="dec-acts"><button type="submit" class="btn primary">Propose</button><button type="button" class="btn" data-action="tp-propose-cancel">Cancel</button></div></form>' : '')
57
+ : ''
58
+ return '<li class="tp-check' + (st ? ' tp-' + st : '') + '" id="tp-' + esc(c.id) + '">' +
59
+ '<div class="tp-ch"><span class="tp-n">' + esc(c.id.slice(1)) + '</span><h3 class="tp-title">' + esc(c.title) + '</h3>' + riskHtml(c.risk, esc) + '<span class="tp-min">' + esc(c.minutes) + ' min</span></div>' +
60
+ (c.where ? '<p class="tp-where"><span class="tp-k">Where</span> ' + esc(c.where) + '</p>' : '') +
61
+ (c.steps && c.steps.length ? '<ol class="tp-steps">' + c.steps.map((x) => '<li>' + esc(x) + '</li>').join('') + '</ol>' : '') +
62
+ (c.expect ? '<p class="tp-expect"><span class="tp-k">You should see</span> ' + esc(c.expect) + '</p>' : '') +
63
+ '<p class="tp-why"><span class="tp-k">Why</span> ' + esc(c.why) + '</p>' +
64
+ '<div class="tp-cites" aria-label="Where this comes from">' + c.cites.map((tg) => citeChipHtml(tg, tags, { esc })).join('') + '</div>' +
65
+ '<div class="tp-acts">' + tick('done', 'Checked', '✓') + tick('failed', 'Failed', '✕') + (st ? '<button type="button" class="linkish tp-clear" data-action="tp-tick" data-id="' + esc(plan.id) + '" data-check="' + esc(c.id) + '" data-state="">Clear</button>' : '') + '</div>' + note + '</li>'
66
+ }
67
+
68
+ // The plan's page. ui: { trusted, noting, machine (another Mac's plan), here }
69
+ export function testplanPageHtml(plan, ui = {}, { esc = escT } = {}) {
70
+ if (!plan) return '<div class="proj tp-page"><p class="faint">Loading the test plan…</p></div>'
71
+ const sc = plan.scope || {}
72
+ const back = sc.kind === 'project' ? '<a class="back" href="#/project/' + esc(encodeURIComponent(sc.id || sc.token || '')) + '">← ' + esc(sc.label || 'Project') + '</a>' : '<a class="back" href="#/prs">← Pull requests</a>'
73
+ const sub = [plan.window && plan.window.label, plan.minutes + '-minute budget', (plan.prs || []).length + ' merged PR' + ((plan.prs || []).length === 1 ? '' : 's'), plan.machine && ui.here && plan.machine !== ui.here ? 'on ' + plan.machine : null].filter(Boolean).join(' · ')
74
+ let h = back + '<header class="tp-head"><h1>Test plan <span class="faint">· ' + esc(sc.label || '') + '</span></h1><p class="sub">' + esc(sub) + '</p></header>'
75
+ if (plan.status === 'running') return '<div class="proj tp-page">' + h + '<div class="tp-wait" role="status" aria-live="polite"><span class="tp-spin" aria-hidden="true"></span> Writing the test plan from ' + esc((plan.prs || []).length) + ' PRs… this takes up to a couple of minutes.</div></div>'
76
+ if (plan.status === 'error') return '<div class="proj tp-page">' + h + '<div class="banner warn" role="alert"><span>Couldn’t write it: ' + esc(plan.error || 'unknown error') + '</span></div></div>'
77
+ const p = plan.plan || { checks: [], notCovered: [], coverage: {} }
78
+ const by = plan.by || {}
79
+ const byWords = by.engine === 'claude' ? 'Written by Claude Code (read-only)' : by.engine === 'summary' ? 'Written by your summary model (' + (by.model || by.provider || '') + ')' : by.engine === 'rules' ? 'Made from Leftoff’s notes without a model' : by.engine === 'empty' ? '' : ''
80
+ h += '<div class="tp-bar"><span class="tp-prog" role="status"><span aria-hidden="true">☐</span> ' + esc(progressLine(plan)) + '</span><span class="tp-cov">' + esc(p.coverage && p.coverage.line || '') + '</span>' +
81
+ '<span class="tp-exp"><button type="button" class="btn" data-action="tp-copy" data-id="' + esc(plan.id) + '">Copy as Markdown</button><button type="button" class="btn" data-action="tp-download" data-id="' + esc(plan.id) + '">Download .html</button></span></div>'
82
+ if (byWords) h += '<p class="faint tp-by">' + esc(byWords) + (by.why && by.engine !== 'claude' ? ' · ' + esc(by.why) : '') + '</p>'
83
+ if (!p.checks.length) h += '<p class="faint">' + esc((p.coverage && p.coverage.line) || 'Nothing to check.') + '</p>'
84
+ else h += '<ol class="tp-list">' + p.checks.map((c) => checkHtml(c, plan, { esc, trusted: ui.trusted, noting: ui.noting, proposing: ui.proposing })).join('') + '</ol>'
85
+ if (p.notCovered && p.notCovered.length) h += '<section class="proj-sec tp-nc"><h2>Not covered <span class="count">' + p.notCovered.length + '</span></h2><ul class="tp-ncl">' + p.notCovered.map((n) => '<li><span class="tp-nc-w">' + esc(n.what) + '</span> <span class="faint">· ' + esc(n.why) + '</span>' + ((n.cites || []).length ? ' <span class="tp-cites">' + n.cites.map((tg) => citeChipHtml(tg, p.tags || {}, { esc })).join('') + '</span>' : '') + '</li>').join('') + '</ul></section>'
86
+ const nw = (plan.prs || []).filter((x) => x.noWalkthrough)
87
+ h += '<section class="proj-sec tp-prs"><h2>Merged PRs <span class="count">' + (plan.prs || []).length + '</span></h2><ul class="tp-prl">' + (plan.prs || []).map((x) => '<li>' + (httpsUrl(x.url) ? '<a href="' + esc(x.url) + '" target="_blank" rel="noopener noreferrer">#' + esc(x.number) + '</a>' : '#' + esc(x.number)) + ' ' + esc(x.title) + (x.noWalkthrough ? ' <span class="tp-nowt"><span aria-hidden="true">◌</span> no walkthrough</span>' : '') + '</li>').join('') + '</ul>' +
88
+ (nw.length ? '<p class="faint">' + nw.length + ' had no walkthrough, so only their files, checkpoints and decisions were read.</p>' : '') + '</section>'
89
+ if (p.notes && p.notes.length) h += '<details class="tp-notes"><summary>What was checked in the answer (' + p.notes.length + ')</summary><ul>' + p.notes.map((n) => '<li>' + esc(n) + '</li>').join('') + '</ul></details>'
90
+ h += '<p class="tp-del"><button type="button" class="linkish" data-action="tp-delete" data-id="' + esc(plan.id) + '">Delete this test plan</button></p>'
91
+ return '<div class="proj tp-page">' + h + '</div>'
92
+ }
93
+
94
+ // Before a run: the PRs in the window, the budget (15 / 30 / 60, a choice in the request, not a setting),
95
+ // "Walk through the N without one first", and the plans made before.
96
+ export function testplanNewHtml(pv, ui = {}, { esc = escT } = {}) {
97
+ const sc = (pv && pv.scope) || {}
98
+ const back = sc.kind === 'project' ? '<a class="back" href="#/project/' + esc(encodeURIComponent(sc.id || sc.token || '')) + '">← ' + esc(sc.label || 'Project') + '</a>' : '<a class="back" href="#/prs">← Pull requests</a>'
99
+ let h = back + '<header class="tp-head"><h1>Test plan <span class="faint">· ' + esc(sc.label || '') + '</span></h1><p class="sub">A short manual script for the PRs merged ' + esc(pv && pv.window ? pv.window.label.toLowerCase() : 'lately') + ': what to click, what you should see, why it’s on the list.</p></header>'
100
+ if (!pv) return '<div class="proj tp-page">' + h + '<p class="faint">Reading the merged PRs…</p></div>'
101
+ const prs = pv.prs || []
102
+ const mins = BUDGETS.includes(ui.minutes) ? ui.minutes : 30
103
+ h += '<div class="tp-budget" role="group" aria-label="Time budget">' + BUDGETS.map((m) => '<button type="button" class="sask-opt" data-action="tp-minutes" data-m="' + m + '" aria-pressed="' + (m === mins) + '">' + m + ' min</button>').join('') + '</div>'
104
+ if ((pv.notes || []).length) h += pv.notes.map((n) => '<p class="faint">' + esc(n) + '</p>').join('')
105
+ if (!prs.length) h += '<p class="faint">No merged PRs ' + esc(pv.window ? pv.window.label.toLowerCase() : '') + '. They show here once the PRs page has read them.</p>'
106
+ else {
107
+ h += '<ul class="tp-prl">' + prs.map((x) => '<li>' + (httpsUrl(x.url) ? '<a href="' + esc(x.url) + '" target="_blank" rel="noopener noreferrer">#' + esc(x.number) + '</a>' : '#' + esc(x.number)) + ' ' + esc(x.title) + ' <span class="faint">· ' + esc(day(x.mergedAt)) + '</span>' + (x.walkthrough ? ' <span class="tp-wt"><span aria-hidden="true">●</span> walkthrough</span>' : ' <span class="tp-nowt"><span aria-hidden="true">◌</span> no walkthrough</span>') + '</li>').join('') + '</ul>'
108
+ const w = pv.walking
109
+ const n = pv.withoutWalkthrough || 0
110
+ h += '<div class="dec-acts tp-go">' + (n ? '<button type="button" class="btn" data-action="tp-walk"' + off(ui.trusted, esc) + (w && w.running ? ' disabled' : '') + '>' + (w && w.running ? 'Walking through ' + (w.done + 1) + ' of ' + w.total + '…' : 'Walk through the ' + n + ' without one first') + '</button>' : '') +
111
+ '<button type="button" class="btn primary" data-action="tp-run"' + off(ui.trusted, esc) + (ui.starting ? ' disabled' : '') + '>' + (ui.starting ? 'Starting…' : 'Make the test plan') + '</button></div>'
112
+ if (ui.trusted === false) h += '<p class="faint dec-trust"><span aria-hidden="true">◌</span> ' + esc(TP_TRUST_TIP) + '</p>'
113
+ }
114
+ const recent = (ui.recent && ui.recent.items) || []
115
+ if (recent.length) h += '<section class="proj-sec"><h2>Earlier test plans</h2><ul class="tp-recent">' + recent.map((r) => '<li><a href="' + esc(testplanHref(r.id, r.machine && r.machine !== ui.here ? r.machine : null)) + '">' + esc((r.scope && r.scope.label) || 'Test plan') + ' · ' + esc(day(r.createdAt)) + '</a> <span class="faint">· ' + esc(r.ticked + ' of ' + r.checks + ' checked') + (r.machine && r.machine !== ui.here ? ' · on ' + esc(r.machine) : '') + '</span></li>').join('') + '</ul></section>'
116
+ return '<div class="proj tp-page">' + h + '</div>'
117
+ }
118
+
119
+ // ------------------------------------------------------------ export
120
+
121
+ const mdEsc = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
122
+ // A checklist with checkboxes: ticked ones [x]; a failed one says so, with its note.
123
+ export function planMarkdown(plan) {
124
+ const p = (plan && plan.plan) || { checks: [], notCovered: [] }
125
+ const t = plan.ticks || {}
126
+ const L = ['# Test plan: ' + mdEsc((plan.scope && plan.scope.label) || ''), '', mdEsc([plan.window && plan.window.label, plan.minutes + '-minute budget', (plan.prs || []).length + ' merged PRs', progressLine(plan)].filter(Boolean).join(' · ')), '']
127
+ if (p.coverage && p.coverage.line) L.push(mdEsc(p.coverage.line), '')
128
+ for (const c of p.checks) {
129
+ const st = t[c.id] && t[c.id].state
130
+ L.push('- [' + (st ? 'x' : ' ') + '] **' + mdEsc(c.title) + '** (' + (RISK_WORD[c.risk] || c.risk) + ', ' + c.minutes + ' min)' + (st === 'failed' ? ' ✕ failed' + (t[c.id].note ? ': ' + mdEsc(t[c.id].note) : '') : ''))
131
+ if (c.where) L.push(' - Where: ' + mdEsc(c.where))
132
+ c.steps.forEach((x, i) => L.push(' ' + (i + 1) + '. ' + mdEsc(x)))
133
+ if (c.expect) L.push(' - You should see: ' + mdEsc(c.expect))
134
+ L.push(' - Why: ' + mdEsc(c.why) + (c.cites.length ? ' (' + c.cites.map((x) => '`' + x + '`').join(' ') + ')' : ''))
135
+ }
136
+ if (p.notCovered && p.notCovered.length) { L.push('', '## Not covered', ''); for (const n of p.notCovered) L.push('- ' + mdEsc(n.what) + ': ' + mdEsc(n.why)) }
137
+ L.push('', '## Merged PRs', '')
138
+ for (const x of plan.prs || []) L.push('- #' + x.number + ' ' + mdEsc(x.title) + (x.noWalkthrough ? ' (no walkthrough)' : ''))
139
+ return L.join('\n') + '\n'
140
+ }
141
+
142
+ // ONE self-contained file in the walkthrough export's look: inline CSS, no scripts, no links out
143
+ // (a link is its words and its address).
144
+ export function planHtmlDoc(plan) {
145
+ const e = escT
146
+ const p = (plan && plan.plan) || { checks: [], notCovered: [] }
147
+ const t = plan.ticks || {}
148
+ let b = '<main><h1>Test plan: ' + e((plan.scope && plan.scope.label) || '') + '</h1><p class="f">' + e([plan.window && plan.window.label, plan.minutes + '-minute budget', (plan.prs || []).length + ' merged PRs', progressLine(plan)].filter(Boolean).join(' · ')) + '</p>'
149
+ if (p.coverage && p.coverage.line) b += '<p class="sum">' + e(p.coverage.line) + '</p>'
150
+ for (const c of p.checks) {
151
+ const st = t[c.id] && t[c.id].state
152
+ b += '<h2>' + (st === 'done' ? '☑' : st === 'failed' ? '☒' : '☐') + ' ' + e(c.title) + '<span class="tag' + (c.risk === 'high' ? ' core' : '') + '">' + (RISK_SHAPE[c.risk] || '') + ' ' + e(RISK_WORD[c.risk] || c.risk) + '</span><span class="tag">' + e(c.minutes) + ' min</span></h2>'
153
+ if (st === 'failed') b += '<div class="co"><b>Failed</b> ' + e((t[c.id] && t[c.id].note) || '') + '</div>'
154
+ if (c.where) b += '<p class="f">Where: ' + e(c.where) + '</p>'
155
+ if (c.steps.length) b += '<ol>' + c.steps.map((x) => '<li>' + e(x) + '</li>').join('') + '</ol>'
156
+ if (c.expect) b += '<p>You should see: ' + e(c.expect) + '</p>'
157
+ b += '<p class="f">Why: ' + e(c.why) + (c.cites.length ? ' · ' + c.cites.map((x) => '<code>' + e(x) + '</code>').join(' ') : '') + '</p>'
158
+ }
159
+ if (p.notCovered && p.notCovered.length) b += '<h2>Not covered</h2><ul>' + p.notCovered.map((n) => '<li>' + e(n.what) + ' <span class="f">(' + e(n.why) + ')</span></li>').join('') + '</ul>'
160
+ b += '<h2>Merged PRs</h2><ul>' + (plan.prs || []).map((x) => '<li>#' + e(x.number) + ' ' + e(x.title) + (x.url ? ' <span class="u f">' + e(x.url) + '</span>' : '') + (x.noWalkthrough ? ' <span class="f">(no walkthrough)</span>' : '') + '</li>').join('') + '</ul></main>'
161
+ return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'"><title>' + e('Test plan: ' + ((plan.scope && plan.scope.label) || '')) + '</title><style>' + EXPORT_CSS + '</style></head><body>' + b + '</body></html>\n'
162
+ }
@@ -0,0 +1,59 @@
1
+ // Full screen on a phone, sized to the window you can see (ui/app.js fitFullViewport).
2
+ // 2026-11-03 (Joel: "weird top padding after sending a message"): iOS pans the
3
+ // visual viewport up when the keyboard opens over the composer, and full screen
4
+ // follows that pan (top = visualViewport.offsetTop) so the composer stays above
5
+ // the keyboard. After a send the keyboard closes, but iOS's last report can
6
+ // still carry the old pan (or no report comes), and full screen stayed ~300px
7
+ // down the screen with the board showing through above it. So the pan only
8
+ // counts while the keyboard is really up (a text box has focus and the window
9
+ // lost a keyboard's worth of height), and never more than the room the
10
+ // keyboard took (a visual viewport as tall as the window can't be panned).
11
+
12
+ // A focused field the on-screen keyboard is up for.
13
+ const NO_KB_INPUT = /^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/i
14
+ export function isTypingTarget(el) {
15
+ if (!el || !el.tagName) return false
16
+ const tag = String(el.tagName).toUpperCase()
17
+ if (tag === 'TEXTAREA') return !el.readOnly && !el.disabled
18
+ if (tag === 'INPUT') return !NO_KB_INPUT.test(el.type || 'text') && !el.readOnly && !el.disabled
19
+ return !!el.isContentEditable
20
+ }
21
+
22
+ export const KEYBOARD_MIN_PX = 120 // less than this is the browser's own bars, not a keyboard
23
+
24
+ // innerHeight: the layout viewport's height; vv: { height, offsetTop, scale }; typing: a text box has focus.
25
+ // -> { h, top, kb } in CSS px (null without a visualViewport: the CSS falls back to 100dvh).
26
+ // 2026-11-04 (switching keyboards mid-typing hid the composer): while a text box has focus, full
27
+ // screen is exactly the visual viewport as iOS reports it right now, whatever the keyboard's height
28
+ // (a keyboard swap reports a passing full-height or tiny keyboard on the way; following each report
29
+ // means the next one puts it right). Nothing is remembered from when the box took focus. `kb` (the
30
+ // keyboard's own look: no home-indicator gap) still needs a keyboard's worth of height.
31
+ export function fullViewportFit({ innerHeight, vv, typing = false } = {}) {
32
+ if (!vv || !(Number(innerHeight) > 0)) return null
33
+ const ih = Number(innerHeight)
34
+ const vh = Number(vv.height) > 0 ? Number(vv.height) : ih
35
+ const zoomed = Number(vv.scale) > 1.01 // pinch-zoomed: follow the visual viewport as before
36
+ const kb = !!typing && ih - vh > KEYBOARD_MIN_PX
37
+ if (!typing && !zoomed) return { h: Math.round(ih), top: 0, kb: false } // no keyboard: the whole window, from its top
38
+ const room = Math.max(0, ih - vh) // a visual viewport as tall as the window can't be panned (a stale pan)
39
+ const top = Math.min(Math.max(0, Number(vv.offsetTop) || 0), room)
40
+ return { h: Math.round(Math.min(vh, ih)), top: Math.round(top), kb }
41
+ }
42
+
43
+ // How far full screen's composer sits outside what you can see (the visual viewport, in the same
44
+ // page coordinates as getBoundingClientRect): > 0 means move it up by that much, < 0 down, 0 it's in view.
45
+ // rect: the composer's { top, bottom }; vv: { height, offsetTop }.
46
+ export function outOfView(rect, vv, slack = 1) {
47
+ if (!rect || !vv || !(Number(vv.height) > 0)) return 0
48
+ const top = Number(vv.offsetTop) || 0, bottom = top + Number(vv.height)
49
+ if (rect.bottom > bottom + slack) return Math.round(Math.min(rect.bottom - bottom, rect.top - top)) // below the keyboard's edge (never past its own top)
50
+ if (rect.top < top - slack) return -Math.round(top - rect.top) // above the top of what you can see
51
+ return 0
52
+ }
53
+
54
+ // When to look again after a text box lets go (the keyboard takes ~0.3–0.5 s to
55
+ // close, and iOS's reports during it can be stale or missing), in ms.
56
+ export const SETTLE_AFTER_BLUR_MS = [0, 120, 350, 700, 1100]
57
+ // The same beats after every report while a text box has focus: a keyboard swap's last change can
58
+ // come with no event of its own, so the board looks again a few times after each one.
59
+ export const SETTLE_WHILE_TYPING_MS = SETTLE_AFTER_BLUR_MS
package/ui/walk-pop.js CHANGED
@@ -67,6 +67,9 @@ export function popContent(kind, { size = null, away = false } = {}) {
67
67
  ? { icon: 'circle-dashed', text: label + ' · no changes yet, so nothing to walk through', dim: true }
68
68
  : { icon: 'git-compare', text: label + ' · ' + plural(size.files, 'file') })
69
69
  }
70
+ // 2026-11-03 what was already changed when the session started isn't counted: said quietly
71
+ if (w && size.excluded > 0) lines.push({ icon: 'circle-dashed', text: plural(size.excluded, 'file') + ' changed before this session, left out', dim: true })
72
+ else if (w && size.fallback) lines.push({ icon: 'circle-dashed', text: 'Only the files this session edited', dim: true })
70
73
  if (size && size.writer) lines.push({ icon: 'bot', text: 'Written by ' + size.writer })
71
74
  return {
72
75
  title: 'Walk me through the changes',
package/ui/what-it-did.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // app.js) so they can be checked without a browser.
6
6
  import { icon } from './icons.js'
7
7
  import { diffBlocks, splitPath } from './diff-view.js'
8
+ import { fileScopeSegHtml, scopeTagHtml, unreadableLines } from './semdiff.js' // 2026-11-03 This session · Whole folder, the walkthrough's own rule
8
9
 
9
10
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
10
11
  const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
@@ -63,12 +64,12 @@ export function fileRowHtml(f, ui, sid) {
63
64
  const bodyId = 'wid-f-' + esc(encodeURIComponent(f.path).replace(/%/g, '_'))
64
65
  const bar = '<span class="gd-bar" aria-hidden="true">' + diffBlocks(f.add, f.del).map((b) => '<i class="gd-b' + b + '"></i>').join('') + '</span>'
65
66
  const counts = f.binary ? '<span class="wid-n">binary</span>' : f.big ? '<span class="wid-n">big</span>' : '<span class="wid-add">+' + esc(f.add) + '</span> <span class="wid-del">−' + esc(f.del) + '</span>' + bar
66
- let h = '<li class="wid-item' + (open ? ' open' : '') + '">'
67
+ let h = '<li class="wid-item' + (open ? ' open' : '') + (f.notSession ? ' nots' : '') + '">'
67
68
  h += '<button type="button" class="wid-row wid-file" data-action="wid-file" data-id="' + esc(sid) + '" data-path="' + esc(f.path) + '" aria-expanded="' + open + '" aria-controls="' + bodyId + '">'
68
69
  h += '<span class="wid-chev" aria-hidden="true">' + icon(open ? 'chevron-down' : 'chevron-right') + '</span>'
69
70
  h += '<span class="wid-st st-' + esc(f.status) + '" data-tip="' + esc(word) + '" aria-label="' + esc(word) + '">' + letter + '</span>'
70
71
  const [dir, base] = splitPath(f.path)
71
- h += '<span class="wid-path mono">' + (dir ? '<span class="wid-dir">' + esc(dir) + '</span>' : '') + '<b class="wid-base">' + esc(base) + '</b></span><span class="wid-counts">' + counts + '</span></button>'
72
+ h += '<span class="wid-path mono">' + (dir ? '<span class="wid-dir">' + esc(dir) + '</span>' : '') + '<b class="wid-base">' + esc(base) + '</b>' + scopeTagHtml(f, { esc }) + '</span><span class="wid-counts">' + counts + '</span></button>'
72
73
  if (open) {
73
74
  const v = ui.files[f.path]
74
75
  h += '<div class="wid-out" id="' + bodyId + '">'
@@ -91,16 +92,22 @@ export function fileRowHtml(f, ui, sid) {
91
92
 
92
93
  function diffHtml(ui, sid) {
93
94
  const d = ui.diff
94
- let h = '<div class="wid-sub"><h3>This folder’s changes</h3>'
95
+ // 2026-11-03 scoped like the walkthrough: This session (the default) or Whole folder, counts on both
96
+ const fsc = d && d.fileScope && d.fileScope.session != null ? d.fileScope : null
97
+ const title = fsc && fsc.key !== 'folder' ? 'This session’s changes' : 'This folder’s changes'
98
+ let h = '<div class="wid-sub"><h3>' + title + '</h3>'
95
99
  if (d && d.folder && !d.tooBig && !d.noRepo) h += '<span class="wid-meta">' + esc(d.folder) + (d.totals && d.totals.files ? ' · ' + plural(d.totals.files, 'file') + ' · <span class="wid-add">+' + esc(d.totals.add) + '</span> <span class="wid-del">−' + esc(d.totals.del) + '</span>' : '') + '</span>'
96
100
  h += '</div>'
101
+ if (fsc) h += '<div class="wid-scope">' + fileScopeSegHtml(fsc, { esc, icon, action: 'wid-files', busy: !!ui.loading }) + '</div>'
97
102
  if (ui.diffError) return h + '<p class="wid-note">' + esc(ui.diffError) + '</p>'
98
103
  if (!d) return h + '<p class="wid-note">Loading…</p>'
99
104
  if (d.tooBig) return h + '<p class="wid-note">Too big to show here.</p>'
100
105
  if (d.noRepo) return h + '<p class="wid-note">' + (d.noRepo === 'not_a_repo' ? 'This folder isn’t a git repository.' : d.noRepo === 'no_git' ? 'git isn’t installed on that Mac.' : 'This session’s folder isn’t there any more.') + '</p>'
101
- if (!d.files || !d.files.length) return h + '<p class="wid-note">No uncommitted changes.</p>'
102
- h += '<ul class="wid-list">' + d.files.map((f) => fileRowHtml(f, ui, sid)).join('') + '</ul>'
106
+ const notes = unreadableLines({ baseNotes: d.baseNotes }).map((l) => '<p class="wid-note wid-scope-n">' + icon('info') + '<span>' + esc(l) + '</span></p>').join('')
107
+ if (!d.files || !d.files.length) return h + notes + '<p class="wid-note">' + (fsc && fsc.key !== 'folder' ? (fsc.folder ? 'No uncommitted changes from this session (' + plural(fsc.folder, 'file') + ' in the whole folder).' : 'No uncommitted changes from this session.') : 'No uncommitted changes.') + '</p>'
108
+ h += notes + '<ul class="wid-list">' + d.files.map((f) => fileRowHtml(f, ui, sid)).join('') + '</ul>'
103
109
  if (d.truncated) h += '<p class="wid-note">Showing the first ' + d.files.length + ' of ' + esc(d.totals.files) + ' files.</p>'
110
+ if (fsc && fsc.key !== 'folder') return h + '<p class="wid-foot">Uncommitted changes this session made in its folder. Whole folder shows everything, other sessions’ changes too.</p>'
104
111
  return h + '<p class="wid-foot">Uncommitted changes in the session’s folder (staged, unstaged and new files). Other sessions in this folder show the same changes.</p>'
105
112
  }
106
113