@leftoff/cli 0.9.17-beta.9 → 0.9.18-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,8 @@
6
6
  // The recap's lines come from the transcript, so they go in as text afterwards
7
7
  // ([data-scu], fillSessionCatchup), never as markup.
8
8
  import { copyButtonHtml, fillCopy } from './copy-msg.js' // 0.9.11: Copy on Claude's messages
9
+ import { mdInline } from './changelog-md.js'
10
+ const SCU_MD = /^(goal|next|b-\d+)$/
9
11
 
10
12
  export function catchupButtonHtml(s, { esc, icon = () => '', busy = false } = {}) {
11
13
  if (!s || s.agent || String(s.id || '').startsWith('agent:')) return ''
@@ -107,7 +109,9 @@ export function fillSessionCatchup(root, ui) {
107
109
  if (!root || !root.querySelectorAll) return 0
108
110
  const t = sessionCatchupTexts(ui)
109
111
  let n = 0
110
- for (const el of root.querySelectorAll('[data-scu]')) { el.textContent = t[el.getAttribute('data-scu')] || ''; n++ }
112
+ // 0.9.19: the recap's own words (goal, lines, Next) render `code` and **bold**: mdInline escapes
113
+ // everything first, so a line is still never markup of its own; the rest stays plain text
114
+ for (const el of root.querySelectorAll('[data-scu]')) { const k = el.getAttribute('data-scu'), v = t[k] || ''; if (SCU_MD.test(k)) el.innerHTML = mdInline(v, { links: false }); else el.textContent = v; n++ }
111
115
  const by = root.querySelector('[data-scu="by"]')
112
116
  if (by && !by.textContent) by.hidden = true
113
117
  const cb = root.querySelector('.scu .msg-copy') // 0.9.11: Copy's words, as an attribute
package/ui/start.js CHANGED
@@ -51,8 +51,36 @@ export function startPick(data, ui = {}) {
51
51
  const where = ui.where === 'worktree' && !(repo && repo.git === false) ? 'worktree' : 'repo'
52
52
  // Permissions: what you picked, else what this board last used for that Mac (kept on the Mac you're connected to), else Ask me
53
53
  const mode = START_MODES.some((x) => x.v === ui.mode) ? ui.mode : m && START_MODES.some((x) => x.v === m.lastMode) ? m.lastMode : 'default'
54
- return { m, repo, where, mode, project, also, dropped, missing }
55
- }
54
+ // 0.9.17 Claude Code or Codex: what you picked (when that Mac has both), else what it used last, else the one it has
55
+ const ag = m && m.agents ? m.agents : null
56
+ const agent = !ag ? 'claude' : ag.choice && (ui.agent === 'claude' || ui.agent === 'codex') ? ui.agent : ag.last || (ag.claude ? 'claude' : ag.codex ? 'codex' : 'claude')
57
+ // 1.0 Mode: what you picked (when it's possible for that agent there), else what that Mac used last for the agent, else how Start ran it before
58
+ const rs = m && m.runs ? m.runs : null
59
+ const okRun = (r) => !!(rs && rs.options && rs.options[agent] && rs.options[agent][r] && rs.options[agent][r].ok)
60
+ const dflt = agent === 'codex' ? 'board' : 'headless'
61
+ const lastRun = rs && rs.last ? rs.last[agent] : null
62
+ const run = !rs ? dflt : START_RUNS.some((x) => x.v === ui.run) && okRun(ui.run) ? ui.run : okRun(lastRun) ? lastRun : okRun(dflt) ? dflt : (START_RUNS.find((x) => okRun(x.v)) || { v: dflt }).v
63
+ return { m, repo, where, mode, project, also, dropped, missing, agent, agents: ag, run, runs: rs }
64
+ }
65
+ // 1.0 where the agent runs (src/start-run.js)
66
+ export const START_RUNS = Object.freeze([
67
+ { v: 'headless', label: 'Headless', sub: 'chat in Live, from any device', glyph: '◇' },
68
+ { v: 'board', label: 'Board terminal', sub: 'a terminal here and on your phone', glyph: '▭' },
69
+ { v: 'app', label: 'My terminal app', sub: 'Ghostty, iTerm or Terminal', glyph: '▣' },
70
+ ])
71
+ export function startRunIntro(agent, run) {
72
+ const AG = agent === 'codex' ? 'Codex' : 'Claude Code'
73
+ if (run === 'board') return 'Leftoff starts ' + AG + ' in a terminal it keeps on the Mac you pick. It shows in the board and on your phone, and it keeps running when you close the tab.'
74
+ if (run === 'app') return 'Leftoff starts ' + AG + ' in a terminal it keeps on the Mac you pick, and opens it in your terminal app. Closing that window doesn’t stop it: Open in Terminal (or leftoff attach) brings it back.'
75
+ return 'Leftoff starts ' + AG + ' on the Mac you pick, with no terminal. It shows on every board right away: talk to it in Live, and approve its edits and commands in Needs you, from any device.'
76
+ }
77
+ export const AGENT_NAMES = Object.freeze({ claude: 'Claude Code', codex: 'Codex' })
78
+ // 0.9.17 the Permissions cards in Codex's words (src/codex-start.js CODEX_MODES_TEXT)
79
+ export const CODEX_MODE_SUBS = Object.freeze({
80
+ default: { sub: 'Codex’s own settings', text: 'Codex runs with your own Codex settings: it asks before anything its sandbox doesn’t allow. You answer in its terminal.' },
81
+ acceptEdits: { sub: 'edits in the folder, asks for the rest', text: 'Codex may edit files and run commands inside the folder (its workspace-write sandbox); anything outside it asks first, in its terminal.' },
82
+ auto: { sub: 'no asks, sandboxed to the folder', text: 'Codex never stops to ask: commands run inside the folder’s sandbox, and anything the sandbox blocks fails back to Codex instead of asking you.' },
83
+ })
56
84
  export const ADD_DIRS_MAX = 8 // src/claude-headless.js
57
85
 
58
86
  // The body New session posts (src/session-start.js): a project start carries
@@ -60,6 +88,8 @@ export const ADD_DIRS_MAX = 8 // src/claude-headless.js
60
88
  // added (0.9.16) carries just those folders.
61
89
  export function startBody(pick, prompt, { draft = null, atts = [] } = {}) {
62
90
  const b = { machine: pick.m.machine, cwd: pick.repo.path, prompt, where: pick.where, mode: pick.mode }
91
+ if (pick.agents) b.agent = pick.agent // 0.9.17 (a Mac that didn't say which it has gets none: Claude Code, as before)
92
+ if (pick.runs) b.run = pick.run // 1.0 (an older Mac gets none: the way it always started)
63
93
  if (pick.project) Object.assign(b, { project: pick.project.id, addDirs: pick.also.map((r) => r.path) })
64
94
  else if (pick.also && pick.also.length) b.addDirs = pick.also.map((r) => r.path) // 0.9.16 + Add another repo
65
95
  const ids = (atts || []).filter((a) => a && a.id && a.status === 'done').map((a) => a.id) // 0.9.13
@@ -127,6 +157,17 @@ export function modePillHtml(s, { esc, seen = null }) {
127
157
  return ' <span class="mode-pill m-' + esc(m) + '" data-tip="' + esc('Permissions: ' + modeLabel(m) + (h.state === 'running' ? '' : ' (when it runs again)')) + '">' + esc(modeLabel(m)) + '</span>'
128
158
  }
129
159
 
160
+ // 1.0 A session in one of Leftoff's terminals: where it runs, by shape and
161
+ // words. ▣ a terminal app is attached; ◌ running · detached (nobody looking;
162
+ // it keeps running, Go or Open in Terminal brings it back); nothing otherwise.
163
+ export function termPillHtml(s, { esc }) {
164
+ const t = s && s.terminal
165
+ if (!t || t.kind !== 'leftoff' || !t.termId || s.endedAt) return ''
166
+ if (t.apps > 0) return ' <span class="term-pill t-app" data-tip="' + esc('Open in a terminal app (leftoff attach). Closing that window leaves it running.') + '"><span aria-hidden="true">▣</span> in a terminal app</span>'
167
+ if (t.detached) return ' <span class="term-pill t-detached" data-tip="' + esc('Its terminal is running with no window on it. Go shows it here; Open in Terminal attaches a window. Only Stop or Exit ends it.') + '"><span aria-hidden="true">◌</span> running · detached</span>'
168
+ return ''
169
+ }
170
+
130
171
  // Is a path inside one of a Mac's approved folders (its real paths)?
131
172
  export function underApproved(p, approved) {
132
173
  const x = String(p || '')
@@ -160,8 +201,8 @@ const ago = (iso, now) => {
160
201
  return Math.round(s / 86400) + 'd ago'
161
202
  }
162
203
 
163
- function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
164
- return '<button type="button" class="start-opt" role="radio" aria-checked="' + on + '" data-action="start-pick" data-k="' + esc(k) + '" data-v="' + esc(v) + '"' + (disabled ? ' disabled' : '') + '>' +
204
+ function opt({ k, v, label, sub = '', on = false, disabled = false, tip = '', esc }) {
205
+ return '<button type="button" class="start-opt" role="radio" aria-checked="' + on + '" data-action="start-pick" data-k="' + esc(k) + '" data-v="' + esc(v) + '"' + (disabled ? ' disabled' : '') + (tip ? ' data-tip="' + esc(tip) + '"' : '') + '>' +
165
206
  '<span class="start-opt-main">' + label + '</span>' + (sub ? '<span class="start-opt-sub">' + sub + '</span>' : '') + '</button>'
166
207
  }
167
208
 
@@ -169,6 +210,7 @@ function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
169
210
  export function startAskLine(ask, { clock = (x) => x } = {}) {
170
211
  if (!ask) return null
171
212
  if (ask.state === 'pending') return { tone: 'wait', text: 'Waiting for a yes on ' + ask.machine + ' (until ' + clock(ask.expiresAt) + '). It shows on that Mac’s board and as a notification.' + (ask.firstTime ? ' A first time in a folder asks there, unless it’s inside a folder approved there.' : '') }
213
+ if (ask.state === 'starting' && ask.agent === 'codex') return { tone: 'wait', text: 'Codex is running in a terminal on ' + ask.machine + '. Its session opens here as soon as Codex writes it (after its first message).' }
172
214
  if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
173
215
  if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
174
216
  if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
@@ -179,8 +221,10 @@ export function startAskLine(ask, { clock = (x) => x } = {}) {
179
221
  export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', local = true, now = Date.now(), clock = (x) => x, draft = '', busy = false, atts = null, chipHtml = null, mayProject = true } = {}) {
180
222
  const pick = startPick(data, ui)
181
223
  const rule = startRule(data, pick, { local })
224
+ const codex = pick.agent === 'codex'
225
+ const AG = AGENT_NAMES[pick.agent] || 'Claude Code'
182
226
  let h = '<a class="back" href="#/">← All sessions</a>' +
183
- '<div class="pagehead"><div><h1>New session</h1><div class="sub">Leftoff starts Claude Code on the Mac you pick. It shows on every board right away: talk to it in Live, approve its edits and commands in Needs you.</div></div></div>'
227
+ '<div class="pagehead"><div><h1>New session</h1><div class="sub">' + esc(startRunIntro(pick.agent, pick.run)) + '</div></div></div>'
184
228
  h += '<div class="start" data-start>'
185
229
  // Mac
186
230
  const ms = (data && data.machines) || []
@@ -188,6 +232,20 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
188
232
  ms.map((x) => opt({ k: 'machine', v: x.machine, on: !!pick.m && x.machine === pick.m.machine, disabled: !x.online, esc,
189
233
  label: esc((emoji(x.machine) ? emoji(x.machine) + ' ' : '') + x.machine), sub: esc(x.here ? (local ? 'this Mac' : 'the Mac you’re connected to') : x.online ? 'online' : 'offline') })).join('') +
190
234
  '</div></section>'
235
+ // Agent (0.9.17): only when that Mac has both
236
+ if (pick.agents && pick.agents.choice) {
237
+ h += '<section class="start-sec start-agent"><h2 class="start-h">Agent</h2><div class="start-opts" role="radiogroup" aria-label="Agent">' +
238
+ opt({ k: 'agent', v: 'claude', on: !codex, esc, label: 'Claude Code', sub: 'Anthropic' }) +
239
+ opt({ k: 'agent', v: 'codex', on: codex, esc, label: 'Codex', sub: 'OpenAI' }) +
240
+ '</div></section>'
241
+ }
242
+ // Mode (1.0): headless, the board's terminal, or your terminal app; the ones that Mac can't do say why
243
+ if (pick.runs && pick.runs.options) {
244
+ const o = pick.runs.options[pick.agent] || {}
245
+ h += '<section class="start-sec start-run"><h2 class="start-h">Mode</h2><div class="start-opts" role="radiogroup" aria-label="Mode">' +
246
+ START_RUNS.map((x) => { const can = o[x.v] || { ok: true }; return opt({ k: 'run', v: x.v, on: x.v === pick.run, disabled: !can.ok, tip: can.ok ? '' : can.why || '', esc, label: '<span class="run-glyph" aria-hidden="true">' + x.glyph + '</span> ' + esc(x.label), sub: esc(can.ok ? x.sub : 'not on this Mac') }) }).join('') +
247
+ '</div></section>'
248
+ }
191
249
  // Project (0.9.12): only when there are projects
192
250
  const projects = (pick.m && pick.m.projects) || []
193
251
  if (projects.length) {
@@ -228,17 +286,17 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
228
286
  // Where
229
287
  h += '<section class="start-sec"><h2 class="start-h">Where</h2><div class="start-opts start-where" role="radiogroup" aria-label="Where">' +
230
288
  opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
231
- opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: pick.repo && pick.repo.git === false ? 'needs a git repo' : 'its own branch and folder', disabled: !!(pick.repo && pick.repo.git === false) }) +
289
+ opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: pick.repo && pick.repo.git === false ? 'needs a git repo' : codex ? 'Codex’s own worktree' : 'its own branch and folder', disabled: !!(pick.repo && pick.repo.git === false) }) +
232
290
  '</div></section>'
233
291
  // Permissions (0.9.9)
234
292
  const mo = START_MODES.find((x) => x.v === pick.mode) || START_MODES[0]
235
- const moreText = mo.text + ' ' + TERMINAL_MODE_HINT // behind the (?): the cards' own lines say enough
293
+ const moreText = codex ? (CODEX_MODE_SUBS[mo.v] || CODEX_MODE_SUBS.default).text : mo.text + ' ' + TERMINAL_MODE_HINT // behind the (?): the cards' own lines say enough
236
294
  h += '<section class="start-sec"><h2 class="start-h start-h-help">Permissions' + helpQHtml({ id: 'start-mode-help', label: 'About Permissions', text: moreText, open: !!ui.helpOpen }, { esc }) + '</h2><div class="start-opts start-mode" role="radiogroup" aria-label="Permissions">' +
237
- START_MODES.map((x) => opt({ k: 'mode', v: x.v, on: x.v === mo.v, esc, label: esc(x.label), sub: esc(x.sub) })).join('') +
295
+ START_MODES.map((x) => opt({ k: 'mode', v: x.v, on: x.v === mo.v, esc, label: esc(x.label), sub: esc(codex ? CODEX_MODE_SUBS[x.v].sub : x.sub) })).join('') +
238
296
  '</div>' + helpMoreHtml({ id: 'start-mode-help', text: moreText, open: !!ui.helpOpen, cls: 'start-hint start-mode-hint' }, { esc }) + '</section>'
239
297
  // First message
240
298
  h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
241
- composerBoxHtml({ submit: 'start', sendAction: 'start-send', draftKey: 'start', draft, ...(Array.isArray(atts) ? { attach: true, atts, chipHtml, sid: START_SID } : {}), may: rule.ok && !ui.ask, busy, placeholder: 'What should Claude do?', label: 'First message', inputClass: 'start-text', keys: 'Enter Meta+Enter Control+Enter' }) +
299
+ composerBoxHtml({ submit: 'start', sendAction: 'start-send', draftKey: 'start', draft, ...(Array.isArray(atts) && !codex ? { attach: true, atts, chipHtml, sid: START_SID } : {}), may: rule.ok && !(ui.ask && ui.ask.state !== 'starting'), busy, placeholder: codex ? 'What should Codex do? (or leave it empty)' : 'What should Claude do?', label: 'First message', inputClass: 'start-text', keys: 'Enter Meta+Enter Control+Enter' }) +
242
300
  '</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>'
243
301
  const line = startAskLine(ui.ask, { clock })
244
302
  if (line) h += '<div class="start-ask ' + line.tone + '" role="status">' + esc(line.text) + (ui.ask && ['declined', 'expired', 'failed'].includes(ui.ask.state) ? ' <button type="button" class="btn" data-action="start-again">Try again</button>' : '') + '</div>'
@@ -458,20 +516,37 @@ export function headlessBarHtml(s, { esc, icon, may = true, busy = null, local =
458
516
  if (!h) return ''
459
517
  const from = s.startedFrom && s.startedFrom.device ? ' from ' + s.startedFrom.device : ''
460
518
  const where = s.machine ? ' on ' + s.machine : ''
519
+ const agent = h.agent === 'codex' || s.codingAgent === 'codex' ? 'Codex' : 'Claude Code'
520
+ // 1.0 the same conversation in a terminal: Leftoff's own (a terminal app attaches to it on the Mac itself)
521
+ const cont = may && h.state !== 'stopping' && h.state !== 'resuming' ? continueButtonHtml(s, 'board', { esc }) : ''
461
522
  let text, btn = ''
462
- if (h.state === 'running') { text = 'Running' + where + ', started' + from + ' by Leftoff' + (h.awake ? ' · keeping it awake' : ''); if (may) btn = '<button type="button" class="btn" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' }
523
+ if (h.state === 'running') { text = 'Running' + where + ', started' + from + ' by Leftoff' + (h.awake ? ' · keeping it awake' : ''); if (may) btn = cont + '<button type="button" class="btn" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' }
463
524
  else if (h.state === 'stopping') text = 'Stopping' + where + '…'
464
- else if (h.state === 'resuming') text = 'Starting Claude Code' + where + '…' // 0.9.9: Resume on its way
525
+ else if (h.state === 'resuming') text = 'Starting ' + agent + where + '…' // 0.9.9: Resume on its way
465
526
  else {
466
527
  // 0.9.13: Paused (its process went: a restart, an exit) is still open; a message resumes it, as does Stopped
467
528
  text = (h.error ? 'Claude Code stopped' + where : h.state === 'paused' ? 'Paused' + where + (h.pausedWhy && h.pausedWhy !== 'restart' ? '' : ' (Leftoff restarted)') : 'Stopped' + where) + (from ? ', started' + from : '') + ' · a message picks it up'
468
- if (may) btn = '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
529
+ if (may) btn = cont + '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
469
530
  }
470
531
  return '<div class="headless-bar ' + esc(h.state) + '" data-sid="' + esc(s.id) + '"><span class="grow">' + icon('play') + esc(text) + '</span>' + btn + '</div>' +
471
532
  (h.error && h.state !== 'running' ? '<pre class="headless-err" data-headless-err="' + esc(s.id) + '"></pre>' : '') +
472
533
  (h.state !== 'stopping' && h.state !== 'resuming' ? headlessModeHtml(s, { esc, may, busy, local, helpOpen }) : '')
473
534
  }
474
535
 
536
+ // 1.0 Continue in terminal / Continue headless: one button, the same conversation the other way
537
+ export function continueButtonHtml(s, run, { esc }) {
538
+ const label = run === 'headless' ? 'Continue headless' : 'Continue in terminal'
539
+ const tip = run === 'headless' ? 'Ends it in the terminal, then picks the same conversation up headless (Live). Only when it’s idle.' : 'Ends the headless process, then picks the same conversation up in a terminal of Leftoff’s. Only when it’s idle.'
540
+ return '<button type="button" class="btn run-continue" data-action="run-continue" data-run="' + esc(run) + '" data-id="' + esc(s.id) + '" data-tip="' + esc(tip) + '"><span class="run-glyph" aria-hidden="true">' + (run === 'headless' ? '◇' : '▭') + '</span>' + esc(label) + '</button>'
541
+ }
542
+ // The same for a session in Leftoff's own terminal (not headless): where it runs, and the way to headless
543
+ export function termBarHtml(s, { esc, may = true } = {}) {
544
+ const t = s && s.terminal
545
+ if (!s || s.headless || s.endedAt || !t || t.kind !== 'leftoff' || !t.termId) return ''
546
+ const where = t.apps > 0 ? 'In a terminal app' : t.detached ? 'Running · detached' : 'In Leftoff’s terminal'
547
+ return '<div class="headless-bar term-bar" data-sid="' + esc(s.id) + '"><span class="grow"><span class="run-glyph" aria-hidden="true">' + (t.apps > 0 ? '▣' : t.detached ? '◌' : '▭') + '</span>' + esc(where + (s.machine ? ' on ' + s.machine : '')) + '</span>' + (may ? continueButtonHtml(s, 'headless', { esc }) : '') + '</div>'
548
+ }
549
+
475
550
  // The session page's Permissions switch for a session Leftoff runs: the same
476
551
  // three as New session. Running, it switches Claude Code mid-session; stopped,
477
552
  // it's the mode Resume starts it in.
@@ -0,0 +1,119 @@
1
+ // 2026-10-30 (walkthrough round 3) Line references for "Ask about this change":
2
+ // click a line number in a walkthrough's diff to pick it, shift-click or drag
3
+ // for a range, Cmd/Ctrl-click to add another range (another file, another
4
+ // step). On a phone: tap to pick, tap another line of the same snippet to
5
+ // stretch it, tap in another snippet to add one. The picks show as chips over
6
+ // the ask box; asking puts each ref and its exact lines into the chat box.
7
+ //
8
+ // A pick is a run of rows of ONE drawn patch (a step's snippet, a file in the
9
+ // Files view), by row index into diff-view.js's parsePatch rows, so it means
10
+ // the same lines in the unified and the split view. Pure: no DOM here.
11
+ import { parsePatch } from './diff-view.js'
12
+
13
+ const rowsCache = new Map()
14
+ // patch -> parsePatch(patch).rows (kept a little while: the same patch is asked for often)
15
+ export function rowsOf(patch) {
16
+ const k = String(patch == null ? '' : patch)
17
+ let r = rowsCache.get(k)
18
+ if (!r) { r = parsePatch(k).rows; rowsCache.set(k, r); if (rowsCache.size > 300) rowsCache.delete(rowsCache.keys().next().value) }
19
+ return r
20
+ }
21
+ const isLine = (r) => r && (r.k === 'add' || r.k === 'del' || r.k === 'ctx')
22
+
23
+ // The numbers a run of rows covers: the new side when any row has a new number
24
+ // (what the file reads now), else the old side (only removed lines).
25
+ export function rangeInfo(rows, a, b) {
26
+ const lo = Math.min(a, b), hi = Math.max(a, b)
27
+ const ls = (rows || []).slice(lo, hi + 1).filter(isLine)
28
+ if (!ls.length) return null
29
+ const ns = ls.filter((r) => r.n != null).map((r) => r.n)
30
+ const side = ns.length ? 'new' : 'old'
31
+ const xs = side === 'new' ? ns : ls.map((r) => r.o).filter((x) => x != null)
32
+ return { side, from: Math.min(...xs), to: Math.max(...xs), lines: ls.length }
33
+ }
34
+ // "src/x.js:12–18", "repo · src/x.js:40", "src/x.js:7–9 (old)" for removed lines only.
35
+ // label: the path as a person reads it (semdiff.js pathLabel)
36
+ export function refLabel(label, info) {
37
+ if (!info) return String(label || '')
38
+ return String(label || '') + ':' + (info.from === info.to ? info.from : info.from + '–' + info.to) + (info.side === 'old' ? ' (old)' : '')
39
+ }
40
+ // The exact lines, the way the walkthrough's model reads them: "+ 13 │ text",
41
+ // "- o12 │ text" (removed, its old number), " 14 │ text" (unchanged).
42
+ export function refLinesText(rows, a, b, { max = 80 } = {}) {
43
+ const lo = Math.min(a, b), hi = Math.max(a, b)
44
+ const ls = (rows || []).slice(lo, hi + 1).filter(isLine)
45
+ const out = ls.slice(0, max).map((r) => (r.k === 'add' ? '+ ' + r.n : r.k === 'del' ? '- o' + r.o : ' ' + r.n) + ' │ ' + r.t)
46
+ if (ls.length > max) out.push(' … ' + (ls.length - max) + ' more lines')
47
+ return out.join('\n')
48
+ }
49
+
50
+ // ---------------------------------------------------------------- picking
51
+ // A pick: { k (which drawn patch), file, step, x (where it started), a, b }.
52
+ // ev: { shift, add (Cmd/Ctrl), touch } -> the new list (never the same array).
53
+ const inside = (p, ri) => ri >= p.a && ri <= p.b
54
+ export function mergePicks(list) {
55
+ const out = []
56
+ for (const p of list) {
57
+ const q = out.find((o) => o.k === p.k && p.a <= o.b + 1 && p.b >= o.a - 1)
58
+ if (q) { q.a = Math.min(q.a, p.a); q.b = Math.max(q.b, p.b) } else out.push({ ...p })
59
+ }
60
+ return out
61
+ }
62
+ export function pickLine(list, at, ev = {}) {
63
+ const cur = (list || []).map((p) => ({ ...p }))
64
+ const one = { k: at.k, file: at.file, step: at.step || null, x: at.ri, a: at.ri, b: at.ri }
65
+ const lastOf = () => { for (let i = cur.length - 1; i >= 0; i--) if (cur[i].k === at.k) return i; return -1 }
66
+ if (ev.shift) {
67
+ const i = lastOf()
68
+ if (i < 0) return mergePicks([...cur, one])
69
+ const p = cur[i]; p.a = Math.min(p.x, at.ri); p.b = Math.max(p.x, at.ri)
70
+ return mergePicks(cur)
71
+ }
72
+ if (ev.add) {
73
+ const i = cur.findIndex((p) => p.k === at.k && inside(p, at.ri))
74
+ if (i >= 0) { cur.splice(i, 1); return cur }
75
+ return mergePicks([...cur, one])
76
+ }
77
+ if (ev.touch) {
78
+ const i = cur.findIndex((p) => p.k === at.k && inside(p, at.ri))
79
+ if (i >= 0) { cur.splice(i, 1); return cur } // a tap on a picked line lets it go
80
+ const j = lastOf()
81
+ if (j >= 0 && j === cur.length - 1) { const p = cur[j]; p.a = Math.min(p.x, at.ri); p.b = Math.max(p.x, at.ri); return mergePicks(cur) } // a second tap stretches it
82
+ return mergePicks([...cur, one])
83
+ }
84
+ if (cur.length === 1 && cur[0].k === at.k && cur[0].a === at.ri && cur[0].b === at.ri) return []
85
+ return [one]
86
+ }
87
+ // dragging: the pick at index i stretched from where it started to ri
88
+ export function dragTo(list, i, ri) {
89
+ const cur = (list || []).map((p) => ({ ...p }))
90
+ const p = cur[i]
91
+ if (!p) return cur
92
+ p.a = Math.min(p.x, ri); p.b = Math.max(p.x, ri)
93
+ return cur
94
+ }
95
+
96
+ // ---------------------------------------------------------------- asking
97
+ // picks + how to read each one -> [{ label, text, step }] ; read(p) -> { rows, label, stepTitle }
98
+ export function describePicks(list, read) {
99
+ const out = []
100
+ for (const p of list || []) {
101
+ const r = read(p)
102
+ if (!r || !r.rows) continue
103
+ const info = rangeInfo(r.rows, p.a, p.b)
104
+ if (!info) continue
105
+ out.push({ label: refLabel(r.label, info), text: refLinesText(r.rows, p.a, p.b), stepTitle: r.stepTitle || '', info })
106
+ }
107
+ return out
108
+ }
109
+ // The words that go into the chat box: each ref with its lines in a diff
110
+ // fence, then the question. refs: describePicks' answer.
111
+ export function refsPrompt(refs, question) {
112
+ const q = String(question || '').trim()
113
+ if (!refs || !refs.length) return q
114
+ const blocks = refs.map((r) => {
115
+ const fence = /```/.test(r.text) ? '~~~~' : '```'
116
+ return '`' + r.label + '`' + (r.stepTitle ? ' (' + r.stepTitle + ')' : '') + ':\n' + fence + 'diff\n' + r.text + '\n' + fence
117
+ })
118
+ return (refs.length === 1 ? 'About these lines:' : 'About these ' + refs.length + ' places:') + '\n\n' + blocks.join('\n\n') + (q ? '\n\n' + q : '')
119
+ }
package/ui/walk-md.js ADDED
@@ -0,0 +1,40 @@
1
+ // 0.9.19 (Joel: "Should we rendering md in the walk through? (and when
2
+ // downloaded)"): a walkthrough's words are Markdown the model wrote, so they
3
+ // render the way the board renders Claude's replies: the same renderer
4
+ // (ui/handoffs.js renderDoc, ui/changelog-md.js mdInline), which escapes
5
+ // everything first (raw HTML shows as text and never runs) and makes a link
6
+ // only for https (new tab, rel=noopener). Titles are inline only (code, bold,
7
+ // italic; no links, since a title sits in buttons and tips); a step's why gets
8
+ // paragraphs, lists and fenced code too. The export uses the same HTML with no
9
+ // copy buttons (it has no script). Copy as Markdown stays the raw text.
10
+ import { mdInline } from './changelog-md.js'
11
+ import { renderDoc } from './handoffs.js'
12
+ import { codeCopyButton } from './code-copy.js'
13
+
14
+ // a title: inline only
15
+ export function walkInline(s) { return mdInline(String(s == null ? '' : s), { links: false, em: true }) }
16
+ // a callout or the summary: inline, with https links
17
+ export function walkInlineLinks(s) { return mdInline(String(s == null ? '' : s), { links: true, em: true }) }
18
+ // The export links out to nothing (one offline file): a link there is its words,
19
+ // with the address after them in brackets when the words aren't the address.
20
+ export function unlink(html) {
21
+ return String(html).replace(/<a href="([^"]*)"(?: target="_blank" rel="noopener")?>([\s\S]*?)<\/a>/g, (_, href, inner) =>
22
+ inner.replace(/<[^>]+>/g, '') === href || inner.replace(/<[^>]+>/g, '') === href.replace(/&amp;/g, '&') ? '<span class="u">' + inner + '</span>' : inner + ' <span class="u">(' + href + ')</span>')
23
+ }
24
+ // a step's why: blocks (paragraphs, lists, fenced code), no file links
25
+ export function walkBlock(s, { exported = false } = {}) {
26
+ const h = renderDoc(String(s == null ? '' : s), { files: false })
27
+ return exported ? unlink(h.split(codeCopyButton()).join('')) : h
28
+ }
29
+ // the export's inline words: rendered, nothing linked
30
+ export function walkInlineExport(s) { return unlink(walkInlineLinks(s)) }
31
+ // the same words as plain text, for a data-tip, an aria-label or the deck's "now" line
32
+ export function mdPlain(s) {
33
+ return String(s == null ? '' : s)
34
+ .replace(/`([^`]+)`/g, '$1')
35
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
36
+ .replace(/\*\*(.+?)\*\*/g, '$1')
37
+ .replace(/~~(.+?)~~/g, '$1')
38
+ .replace(/(^|[^\w*])\*([^*\s][^*]*?)\*(?![\w*])/g, '$1$2')
39
+ .replace(/(^|[^\w])_([^_\s][^_]*?)_(?!\w)/g, '$1$2')
40
+ }
package/ui/walk-pop.js CHANGED
@@ -15,20 +15,39 @@ export function sizeWords(z) {
15
15
  if (z.files === 0) return { kind: 'none', text: 'No changes yet' }
16
16
  return { kind: 'some', text: plural(z.files, 'file') + ' · +' + n0(z.add) + ' −' + n0(z.del) }
17
17
  }
18
- export function sizeLineHtml(z, { esc, icon }) {
18
+ export function sizeLineHtml(z, { esc, icon, reviewed = null }) {
19
19
  const w = sizeWords(z)
20
20
  if (!w) return ''
21
- return w.kind === 'none'
21
+ return (w.kind === 'none'
22
22
  ? '<span class="fs-wt-meta none">' + icon('circle-dashed') + esc(w.text) + '</span>'
23
- : '<span class="fs-wt-meta">' + esc(w.text) + '</span>'
23
+ : '<span class="fs-wt-meta">' + esc(w.text) + '</span>') + walkBadgeHtml(walkProgress(z, reviewed), { esc, icon })
24
+ }
25
+ // 2026-10-30 (asked: "if you've gone through a walk through, should we have a way of denoting that
26
+ // in the right sidebar where that button is?"): the walkthrough you last opened for this scope
27
+ // (change-size's walk: { fp, steps, changed }) and how many of its steps you marked reviewed ->
28
+ // null (never opened, or nothing marked yet) | { kind: 'part' | 'done', text, tip }
29
+ export function walkProgress(z, reviewed) {
30
+ const wk = z && z.walk
31
+ const n = wk && Number(wk.steps)
32
+ const r = Math.max(0, Math.min(n || 0, Number(reviewed) || 0))
33
+ if (!n || !r) return null
34
+ const ch = wk.changed > 0 ? wk.changed + (wk.changed === 1 ? ' file' : ' files') + ' changed since' : ''
35
+ const done = r >= n
36
+ const text = (done ? 'Reviewed' : r + ' of ' + n + ' reviewed') + (ch ? ' · ' + ch : '')
37
+ const tip = (done ? 'You marked every step of this walkthrough reviewed' : 'You marked ' + r + ' of its ' + n + ' steps reviewed') + (ch ? '; ' + ch + ' it was written' : '')
38
+ return { kind: done ? 'done' : 'part', text, tip }
39
+ }
40
+ export function walkBadgeHtml(p, { esc, icon }) {
41
+ if (!p) return ''
42
+ return '<span class="fs-wt-rv fs-wt-rv-' + p.kind + '" data-tip="' + esc(p.tip) + '">' + (p.kind === 'done' ? icon('check') : '<i class="wt-chg-g" aria-hidden="true"></i>') + '<span>' + esc(p.text) + '</span></span>'
24
43
  }
25
44
 
26
45
  // the button (and, on touch, its (i)); data-pop names the popover it explains itself with
27
- export function walkButtonHtml(s, { esc, icon, on = false, size = null }) {
46
+ export function walkButtonHtml(s, { esc, icon, on = false, size = null, reviewed = null }) {
28
47
  const id = esc(s.id)
29
48
  return '<span class="fs-wt-wrap">' +
30
49
  '<button type="button" class="btn fs-wt' + (on ? ' on' : '') + (sizeWords(size) && sizeWords(size).kind === 'none' ? ' none' : '') + '" data-action="' + (on ? 'wt-close' : 'wt-open') + '" data-id="' + id + '" aria-pressed="' + on + '" data-pop="wt">' +
31
- icon('git-compare') + '<span class="fs-wt-l"><span class="fs-wt-v">Walk me through it</span><span class="fs-wt-size" data-id="' + id + '">' + sizeLineHtml(size, { esc, icon }) + '</span></span></button>' +
50
+ icon('git-compare') + '<span class="fs-wt-l"><span class="fs-wt-v">Walk me through it</span><span class="fs-wt-size" data-id="' + id + '">' + sizeLineHtml(size, { esc, icon, reviewed }) + '</span></span></button>' +
32
51
  '<button type="button" class="fs-pop-i" data-pop-open="wt" data-id="' + id + '" aria-label="What Walk me through it does" aria-expanded="false">' + icon('info') + '</button>' +
33
52
  '</span>'
34
53
  }
@@ -0,0 +1,89 @@
1
+ // 2026-10-30 (walkthrough round 3) "Next spot to really look at": the button in
2
+ // the walkthrough's toolbar (and the n key) jumps to the next place that
3
+ // deserves a real look, across steps, in reading order: the lines a callout
4
+ // flags, the spots the model named (src/semdiff.js validateSpots), and, when
5
+ // it named none, this file's heuristic: each Core snippet, plus risky-looking
6
+ // changes anywhere (removed logic, auth and permissions, config and
7
+ // migrations, error handling). Supporting snippets only when nothing else is
8
+ // left. Each spot carries a short reason, shown as a tag at the line. Pure.
9
+ import { parsePatch } from './diff-view.js'
10
+
11
+ export const SPOT_WORDS = { look: 'Look closely', assumption: 'Assumption', untested: 'Untested' }
12
+ const AUTH_WORDS = ['auth', 'permission', 'role', 'admin', 'token', 'secret', 'password', 'passwd', 'csrf', 'session', 'login', 'logout', 'acl', 'grant', 'privilege', 'sudo', 'jwt', 'oauth', 'cookie', 'signature', 'verify']
13
+ // a word on its own or the start of a camelCase part ("isAdmin", "checkPermission", "AUTH_KEY")
14
+ const AUTH = new RegExp('(?:(?<![A-Za-z])(?:' + AUTH_WORDS.join('|') + ')|(?<![A-Za-z])(?:' + AUTH_WORDS.map((x) => x.toUpperCase()).join('|') + ')|(?<=[a-z])(?:' + AUTH_WORDS.map((x) => x[0].toUpperCase() + x.slice(1)).join('|') + ')|(?<![A-Za-z])(?:' + AUTH_WORDS.map((x) => x[0].toUpperCase() + x.slice(1)).join('|') + '))')
15
+ const ERR = /\b(catch|throw|throws|raise|except|finally|reject|rescue|panic|recover|try)\b|\.catch\(|\berr(or)?s?\b/i
16
+ const MIGRATION = /(^|\/)(migrations?|migrate|db\/schema)(\/|\.)|\.sql$/i
17
+ const CONFIG = /(^|\/)(config|settings|\.github\/workflows)(\/|\.|$)|\.(ya?ml|toml|ini|conf)$|(^|\/)(Dockerfile|package\.json|\.env\.example|wrangler\.toml|tsconfig\.json)$/i
18
+ const COMMENT = /^\s*(\/\/|#|\*|\/\*|--|;)/
19
+
20
+ const isLine = (r) => r.k === 'add' || r.k === 'del' || r.k === 'ctx'
21
+ const sideOf = (r) => (r.k === 'del' ? 'old' : 'new')
22
+ const numOf = (r) => (r.k === 'del' ? r.o : r.n)
23
+
24
+ // One snippet (a patch, its file) -> { why, side, line, risk } or null when nothing in it changed.
25
+ export function snippetRisk(patch, file) {
26
+ const rows = parsePatch(patch).rows
27
+ const ch = rows.filter((r) => r.k === 'add' || r.k === 'del')
28
+ if (!ch.length) return null
29
+ const at = (r, why, risk = true) => ({ why, side: sideOf(r), line: numOf(r), risk })
30
+ const first = ch.find((r) => r.k === 'add') || ch[0] // what the file reads now, when there's any
31
+ if (MIGRATION.test(String(file || ''))) return at(first, 'Migration')
32
+ // removed logic: a run of 3+ removed code lines with (almost) nothing added in its place
33
+ for (let i = 0; i < rows.length; i++) {
34
+ if (rows[i].k !== 'del') continue
35
+ let j = i, dels = 0, adds = 0
36
+ while (j < rows.length && (rows[j].k === 'del' || rows[j].k === 'add')) { if (rows[j].k === 'del' && rows[j].t.trim() && !COMMENT.test(rows[j].t)) dels++; if (rows[j].k === 'add' && rows[j].t.trim()) adds++; j++ }
37
+ if (dels >= 3 && adds * 2 < dels) return at(rows[i], 'Removed logic')
38
+ i = j - 1
39
+ }
40
+ const code = ch.filter((r) => r.t.trim() && !COMMENT.test(r.t))
41
+ const a = code.find((r) => AUTH.test(r.t))
42
+ if (a) return at(a, 'Auth / permissions')
43
+ const e = code.find((r) => ERR.test(r.t))
44
+ if (e) return at(e, 'Error handling')
45
+ if (CONFIG.test(String(file || ''))) return at(first, 'Config')
46
+ return at(first, 'Core change', false)
47
+ }
48
+
49
+ // data (a walkthrough) + its steps in reading order -> the spots, in order:
50
+ // [{ step, snip, hunk, file, side, line, why, kind }] (kind: callout | model | risk | core | sup)
51
+ export function spotsFor(data, order) {
52
+ const steps = order || (data && data.steps) || []
53
+ const model = data && Array.isArray(data.spots) ? data.spots : []
54
+ const main = [], sup = []
55
+ for (const st of steps) {
56
+ const core = st.tag === 'Core'
57
+ const snips = st.snippets || []
58
+ snips.forEach((sn, i) => {
59
+ const here = []
60
+ const lineIn = (side, line) => parsePatch(sn.patch).rows.some((r) => isLine(r) && (side === 'old' ? r.o : r.n) === line)
61
+ for (const c of st.callouts || []) if (c.hunk === sn.hunk && lineIn(c.side, c.line)) here.push({ side: c.side, line: c.line, why: SPOT_WORDS[c.kind] || SPOT_WORDS.look, kind: 'callout' })
62
+ for (const m of model) if (m.step === st.id && m.hunk === sn.hunk && lineIn(m.side, m.line) && !here.some((x) => x.side === m.side && x.line === m.line)) here.push({ side: m.side, line: m.line, why: m.why || 'Worth a look', kind: 'model' })
63
+ if (!here.length && !model.length) {
64
+ const r = snippetRisk(sn.patch, sn.file)
65
+ if (r && (r.risk || core)) here.push({ side: r.side, line: r.line, why: r.why, kind: r.risk ? 'risk' : 'core' })
66
+ else if (r) sup.push({ step: st.id, snip: i, hunk: sn.hunk, file: sn.file, side: r.side, line: r.line, why: 'Supporting', kind: 'sup' })
67
+ } else if (!here.length && core) {
68
+ // the model named spots, just none here: a Core snippet still gets one
69
+ const r = snippetRisk(sn.patch, sn.file)
70
+ if (r) here.push({ side: r.side, line: r.line, why: r.risk ? r.why : 'Core change', kind: r.risk ? 'risk' : 'core' })
71
+ }
72
+ here.sort((x, y) => x.line - y.line)
73
+ for (const x of here) main.push({ step: st.id, snip: i, hunk: sn.hunk, file: sn.file, ...x })
74
+ })
75
+ }
76
+ return main.length ? main : sup
77
+ }
78
+
79
+ // The spot after (dir 1) or before (-1) index cur, wrapping; -1 when there are none.
80
+ export function nextSpotIndex(n, cur, dir = 1) {
81
+ if (!n) return -1
82
+ if (cur == null || cur < 0) return dir > 0 ? 0 : n - 1
83
+ return ((cur + dir) % n + n) % n
84
+ }
85
+ // "2 of 7 spots" once you've started, "7 spots" before
86
+ export function spotCountWords(n, cur) {
87
+ if (!n) return 'No spots'
88
+ return (cur != null && cur >= 0 ? (cur + 1) + ' of ' + n : String(n)) + (n === 1 ? ' spot' : ' spots')
89
+ }