@leftoff/cli 0.9.17-beta.8 → 0.9.17

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/back-line.js CHANGED
@@ -5,6 +5,9 @@
5
5
  // × hides that one line for this tab (sessionStorage); a new recap or
6
6
  // checkpoint brings it back. Pure apart from the store the board passes.
7
7
 
8
+ import { mdInline } from './changelog-md.js'
9
+ // 0.9.19: the words are Claude's or the summarizer's, so `code` and **bold** render (escaped first, no links)
10
+ const md = (x) => mdInline(x, { links: false })
8
11
  const MIN = 60000
9
12
  export const BACK_AWAY_MS = 30 * MIN
10
13
  export const BACK_PREFIX = 'lo-back-x:'
@@ -35,8 +38,8 @@ export function backLineHtml(s, line, { esc, icon = () => '', ago = () => '' })
35
38
  const when = line.at ? ago(line.at) : ''
36
39
  const label = line.kind === 'recap' ? 'Recap' : 'Where it stopped'
37
40
  const tip = (line.kind === 'recap' ? 'Claude Code’s recap' : 'The latest checkpoint') + (when ? ' · ' + when : '')
38
- const body = line.kind === 'recap' ? esc(line.text)
39
- : (line.goal ? '<span class="bl-goal">' + esc(line.goal) + '</span> · ' : '') + esc(line.stopped) + (line.next ? ' <span class="bl-next">Next: ' + esc(line.next) + '</span>' : '')
41
+ const body = line.kind === 'recap' ? md(line.text)
42
+ : (line.goal ? '<span class="bl-goal">' + md(line.goal) + '</span> · ' : '') + md(line.stopped) + (line.next ? ' <span class="bl-next">Next: ' + md(line.next) + '</span>' : '')
40
43
  return '<div class="back-line" role="note" data-key="' + esc(line.key) + '"><p data-tip="' + esc(tip) + '"><span class="rl-k">' + label + '</span> ' + body + '</p>' +
41
44
  '<button type="button" class="back-x" data-action="back-dismiss" data-id="' + esc(s.id) + '" data-key="' + esc(line.key) + '" aria-label="Hide this line" data-tip="Hide">' + icon('x') + '</button></div>'
42
45
  }
package/ui/diff-view.js CHANGED
@@ -238,19 +238,80 @@ export function renderDiff(doc, text, { label = '', numbers = true } = {}) {
238
238
  const mk = el('span', 'gd-m', MARK[r.k])
239
239
  mk.setAttribute('aria-label', r.k === 'add' ? 'added' : r.k === 'del' ? 'removed' : 'unchanged')
240
240
  row.appendChild(mk)
241
+ row.appendChild(lineCell(doc, el, r))
242
+ }
243
+ root.appendChild(row)
244
+ }
245
+ return root
246
+ }
247
+ // a line's text, the changed words marked (textContent only)
248
+ function lineCell(doc, el, r) {
249
+ const c = el('span', 'gd-c')
250
+ if (r.w && r.w.length) {
251
+ let at = 0
252
+ for (const [s, e] of r.w) {
253
+ if (s > at) c.appendChild(doc.createTextNode(r.t.slice(at, s)))
254
+ c.appendChild(el(r.k === 'add' ? 'ins' : 'del', 'gd-x', r.t.slice(s, e)))
255
+ at = e
256
+ }
257
+ if (at < r.t.length) c.appendChild(doc.createTextNode(r.t.slice(at)))
258
+ } else if (r.t) c.textContent = r.t
259
+ else c.textContent = '\u200b' // keeps an empty line's height
260
+ return c
261
+ }
262
+
263
+ // 2026-10-30 (walkthrough round 3) the same patch side by side: old on the left,
264
+ // new on the right, a run of removed lines beside the added lines that replaced
265
+ // it (paired in order), unchanged lines on both sides, the @@ bars across. -> the
266
+ // split rows: [{ k: 'line', l: rowIndex | null, r: rowIndex | null } | { k: 'span', i }],
267
+ // indexes into parsePatch's rows (so a picked line means the same in both views).
268
+ export function splitRows(rows) {
269
+ const out = []
270
+ let dels = [], adds = []
271
+ const flush = () => { const n = Math.max(dels.length, adds.length); for (let i = 0; i < n; i++) out.push({ k: 'line', l: i < dels.length ? dels[i] : null, r: i < adds.length ? adds[i] : null }); dels = []; adds = [] }
272
+ rows.forEach((r, i) => {
273
+ if (r.k === 'del') { if (adds.length) flush(); dels.push(i) } else if (r.k === 'add') adds.push(i)
274
+ else { flush(); out.push(r.k === 'ctx' ? { k: 'line', l: i, r: i } : { k: 'span', i }) }
275
+ })
276
+ flush()
277
+ return out
278
+ }
279
+ export function renderSplit(doc, text, { label = '' } = {}) {
280
+ const { rows, maxLine } = parsePatch(text)
281
+ const el = (tag, cls, txt) => { const e = doc.createElement(tag); if (cls) e.className = cls; if (txt != null && txt !== '') e.textContent = txt; return e }
282
+ const root = el('div', 'gd gd-split gd-w' + Math.min(6, Math.max(2, String(maxLine || 1).length)))
283
+ if (label) root.setAttribute('aria-label', label)
284
+ const half = (i, side) => {
285
+ if (i == null) { const h = el('div', 'gd-half gd-h' + side + ' gd-empty'); h.setAttribute('aria-hidden', 'true'); return h }
286
+ const r = rows[i]
287
+ const k = r.k === 'ctx' ? 'ctx' : r.k
288
+ const h = el('div', 'gd-half gd-h' + side + ' gd-' + k)
289
+ h.dataset.ri = String(i)
290
+ const num = side === 'l' ? r.o : r.n
291
+ h.appendChild(el('span', 'gd-gut ' + (side === 'l' ? 'gd-o' : 'gd-n'), num == null ? '' : String(num)))
292
+ const mk = el('span', 'gd-m', MARK[k])
293
+ mk.setAttribute('aria-label', k === 'add' ? 'added' : k === 'del' ? 'removed' : 'unchanged')
294
+ h.appendChild(mk)
295
+ h.appendChild(lineCell(doc, el, r))
296
+ return h
297
+ }
298
+ for (const s of splitRows(rows)) {
299
+ if (s.k === 'span') {
300
+ const r = rows[s.i]
301
+ const row = el('div', 'gd-row gd-' + r.k)
302
+ row.dataset.ri = String(s.i)
303
+ row.appendChild(el('span', 'gd-gut gd-span', r.k === 'gap' ? '⋯' : ''))
241
304
  const c = el('span', 'gd-c')
242
- if (r.w && r.w.length) {
243
- let at = 0
244
- for (const [s, e] of r.w) {
245
- if (s > at) c.appendChild(doc.createTextNode(r.t.slice(at, s)))
246
- c.appendChild(el(r.k === 'add' ? 'ins' : 'del', 'gd-x', r.t.slice(s, e)))
247
- at = e
248
- }
249
- if (at < r.t.length) c.appendChild(doc.createTextNode(r.t.slice(at)))
250
- } else if (r.t) c.textContent = r.t
251
- else c.textContent = '\u200b' // keeps an empty line's height
305
+ if (r.k === 'gap') c.textContent = r.n + ' unchanged line' + (r.n === 1 ? '' : 's')
306
+ else if (r.k === 'hunk') { c.appendChild(el('span', 'gd-range', r.range)); if (r.ctx) { c.appendChild(doc.createTextNode(' ')); c.appendChild(el('span', 'gd-ctx', r.ctx)) } }
307
+ else c.textContent = r.t
252
308
  row.appendChild(c)
309
+ root.appendChild(row)
310
+ continue
253
311
  }
312
+ const row = el('div', 'gd-srow')
313
+ row.appendChild(half(s.l, 'l'))
314
+ row.appendChild(half(s.r, 'r'))
254
315
  root.appendChild(row)
255
316
  }
256
317
  return root
@@ -0,0 +1,66 @@
1
+ // 2026-10-29 A file path in a message is a link only when the board can read
2
+ // that file (Joel: "in the chat all files are clickable and open in the modal
3
+ // but not available"). The board reads a session's file on the machine that
4
+ // has it: this machine; another machine over the signed, sealed peer link the
5
+ // Files panel already uses (src/engine.js sessionFile -> viaPeer); or, on the
6
+ // hosted board, straight from that machine through the relay
7
+ // (ui/relay-client.js ownerRoute). Nothing new is opened to the network here:
8
+ // this only decides whether to offer the link. Otherwise the path stays plain
9
+ // monospace text and its tip says why. Pure (test/unit/calmside-1029.test.mjs).
10
+
11
+ const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
12
+
13
+ // s: the session (isLocal, machine, peer: { ok, code }, agent)
14
+ // relay: null off the hosted board; else { macs: [{ machine, online, serves, platform }] } from the relay's status
15
+ // -> { ok: true } | { ok: false, why: 'agent' | 'offline' | 'sandbox' | 'unreachable', tip }
16
+ export function fileReach(s, { relay = null } = {}) {
17
+ if (!s) return { ok: true } // no session to ask about: the viewer says what it can
18
+ if (s.agent || /^agent:/.test(String(s.id || ''))) return { ok: false, why: 'agent', tip: 'External agents don’t share files' }
19
+ if (s.isLocal !== false) return { ok: true }
20
+ const m = String(s.machine || 'its machine')
21
+ const x = relay && Array.isArray(relay.macs) ? relay.macs.find((y) => y && y.machine === m) : null
22
+ const peer = s.peer || {}
23
+ if (x && !x.online) return { ok: false, why: 'offline', tip: 'On ' + m + ' (offline). Open the board on ' + m + ' to view' }
24
+ if (peer.ok || peer.code === 'checking') return { ok: true }
25
+ // the hosted board goes straight to the session's machine when that machine can open this browser's key
26
+ if (x && x.online && x.serves !== 0) return { ok: true }
27
+ if (x && x.online) return { ok: false, why: 'sandbox', tip: x.platform && x.platform !== 'darwin' ? 'On ' + m + '. Can’t read files from a sandbox here; open the board on ' + m + ' to view' : 'On ' + m + ', which can’t share its files with this device yet' }
28
+ if (peer.code === 'unreachable' || !x) return { ok: false, why: peer.code === 'unreachable' ? 'offline' : 'unreachable', tip: peer.code === 'unreachable' ? 'On ' + m + ' (offline). Open the board on ' + m + ' to view' : 'On ' + m + ', which this board can’t reach right now' }
29
+ return { ok: false, why: 'unreachable', tip: 'On ' + m + ', which this board can’t reach right now' }
30
+ }
31
+
32
+ // The plain-text form of a path link (the words as shown; the path kept for when it can be read again)
33
+ export function offRefHtml({ path, line = null, shownHtml, tip }, { esc = escD } = {}) {
34
+ return '<span class="file-ref-off" tabindex="0" data-path="' + esc(path) + '"' + (line ? ' data-line="' + esc(String(line)) + '"' : '') + ' data-tip="' + esc(tip) + '" aria-label="' + esc(String(path) + '. ' + tip) + '">' + shownHtml + '</span>'
35
+ }
36
+
37
+ // In the page: every path link a session's machine can't serve becomes plain
38
+ // text, and back to a link once it can. reachFor(el) -> { ok, tip } for the
39
+ // session that element belongs to. Returns how many changed.
40
+ export function gateFileRefs(root, reachFor) {
41
+ if (!root || !root.querySelectorAll) return 0
42
+ let n = 0
43
+ for (const a of root.querySelectorAll('a.file-ref')) {
44
+ const r = reachFor(a)
45
+ if (!r || r.ok) continue
46
+ const sp = a.ownerDocument.createElement('span')
47
+ sp.className = 'file-ref-off'; sp.tabIndex = 0
48
+ sp.setAttribute('data-path', a.getAttribute('data-path') || '')
49
+ if (a.getAttribute('data-line')) sp.setAttribute('data-line', a.getAttribute('data-line'))
50
+ sp.setAttribute('data-tip', r.tip); sp.setAttribute('aria-label', (a.getAttribute('data-path') || '') + '. ' + r.tip)
51
+ while (a.firstChild) sp.appendChild(a.firstChild)
52
+ a.replaceWith(sp); n++
53
+ }
54
+ for (const sp of root.querySelectorAll('span.file-ref-off')) {
55
+ const r = reachFor(sp)
56
+ if (!r || !r.ok) { if (r && r.tip && sp.getAttribute('data-tip') !== r.tip) sp.setAttribute('data-tip', r.tip); continue }
57
+ const a = sp.ownerDocument.createElement('a')
58
+ a.className = 'file-ref'; a.href = '#'
59
+ a.setAttribute('data-action', 'file-ref'); a.setAttribute('data-path', sp.getAttribute('data-path') || '')
60
+ if (sp.getAttribute('data-line')) a.setAttribute('data-line', sp.getAttribute('data-line'))
61
+ a.setAttribute('data-tip', 'Open in Leftoff')
62
+ while (sp.firstChild) a.appendChild(sp.firstChild)
63
+ sp.replaceWith(a); n++
64
+ }
65
+ return n
66
+ }
package/ui/fs-side.js CHANGED
@@ -71,12 +71,12 @@ export function railHtml({ cpCount = 0, cu = true, away = '' } = {}, { esc, icon
71
71
  // The whole panel. side/foot: the panel's markup (built by app.js from the
72
72
  // session); label: the peek's one line; collapsed: the rail instead (desktop);
73
73
  // sheet: the phone's sheet is raised.
74
- export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cpCount = 0, cu = true, wt = false, look = 0, collapsed = false, sheet = false, width = SIDE_W_DEFAULT, viewport = 0 }, { esc, icon }) {
74
+ export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cpCount = 0, cu = true, wt = false, look = 0, collapsed = false, sheet = false, scrolled = false, width = SIDE_W_DEFAULT, viewport = 0 }, { esc, icon }) {
75
75
  const peek = '<div class="fs-peek">' +
76
76
  '<button type="button" class="fs-peek-b" data-action="fs-sheet" aria-expanded="' + (sheet ? 'true' : 'false') + '" aria-controls="fsSide"><span class="fs-grip" aria-hidden="true"></span><span class="fs-peek-t">' + esc(label) + '</span>' + icon(sheet ? 'chevron-down' : 'chevron-up') + '</button>' +
77
77
  (cu ? '<button type="button" class="btn fs-peek-cu' + (away ? ' primary' : '') + '" data-action="scu-open" data-id="' + esc(id) + '"' + (away ? ' aria-label="' + esc(away + ' · Catch me up') + '"' : '') + '>' + icon('coffee') + 'Catch me up</button>' : '') +
78
78
  // 0.9.17 Walk me through it, beside Catch me up in the peek (its eye count when there's something to look at)
79
79
  (wt ? '<button type="button" class="btn fs-peek-wt" data-action="wt-open" data-id="' + esc(id) + '" aria-label="' + esc('Walk me through it' + (look ? ', ' + look + ' to look closely at' : '')) + '" data-tip="Walk me through the code changes">' + icon('git-compare') + (look ? '<span class="fs-peek-n" aria-hidden="true">' + esc(String(look)) + '</span>' : '') + '</button>' : '') + '</div>'
80
- return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
80
+ return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + (scrolled ? ' is-scrolled' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
81
81
  resizeHandleHtml({ width, viewport }, { esc }) + peek + '<div class="fs-side-in">' + side + '</div>' + foot + railHtml({ cpCount, cu, away }, { esc, icon }) + '</aside>'
82
82
  }
package/ui/icons.js CHANGED
@@ -3,6 +3,10 @@
3
3
  // so they follow light and dark; always aria-hidden next to a text label.
4
4
  // index.html carries the nav's copies inline (no script needed to draw them).
5
5
  export const ICONS = {
6
+ "crosshair": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M22 12h-4M6 12H2M12 6V2M12 22v-4\"/>",
7
+ "columns-2": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M12 3v18\"/>",
8
+ "chevrons-down-up": "<path d=\"m7 20 5-5 5 5\"/> <path d=\"m7 4 5 5 5-5\"/>",
9
+ "chevrons-up-down": "<path d=\"m7 15 5 5 5-5\"/> <path d=\"m7 9 5-5 5 5\"/>",
6
10
  "panel-left-close": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M9 3v18\"/> <path d=\"m16 15-3-3 3-3\"/>",
7
11
  "panel-left-open": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M9 3v18\"/> <path d=\"m14 9 3 3-3 3\"/>",
8
12
  "menu": "<path d=\"M4 5h16\"/> <path d=\"M4 12h16\"/> <path d=\"M4 19h16\"/>",
package/ui/live-view.js CHANGED
@@ -168,7 +168,7 @@ export function senderLabel(m) {
168
168
  if (!device && by.transport === 'local') device = 'this Mac'
169
169
  if (!device && m.where) { // older entries: just the where line
170
170
  const w = String(m.where)
171
- device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || null
171
+ device = /^on this machine/.test(w) ? 'this Mac' : (w.match(/ as ([^(·]+?)(?: \(|$| ·)/) || [])[1] || (w.match(/^from (.+?)’s board/) || [])[1] || (w.match(/^from (.+?) · (?:via relay|direct)\b/) || [])[1] || null // 2026-10-29: "from iPhone · via relay"
172
172
  }
173
173
  let label = 'You' + (device ? ' · from ' + device : '')
174
174
  const path = PATHS[by.transport] || null
package/ui/prs.js CHANGED
@@ -93,6 +93,11 @@ export async function prCopyClick(btn, env = {}) {
93
93
  export function prMoreHtml(p, open = false) {
94
94
  return ' · <button type="button" class="pr-more pr-toggle" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + !!open + '">' + (open ? 'Less' : 'More…') + '</button>'
95
95
  }
96
+ // 2026-10-29 a compact row (closed): GitHub only; Copy link and Unlink come with the details
97
+ export function prGhOnlyHtml(p) {
98
+ const url = safeUrl(p.url)
99
+ return url ? '<div class="pr-actions"><a class="pr-ic pr-gh" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open on GitHub" aria-label="Open ' + esc('#' + (p.number || '')) + ' on GitHub">' + GH_MARK + '</a></div>' : ''
100
+ }
96
101
  export function prActionsHtml(p, { unlinkId = null } = {}) {
97
102
  const url = safeUrl(p.url), n = '#' + (p.number || '')
98
103
  if (!url && !unlinkId) return ''
@@ -113,21 +118,25 @@ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {}
113
118
  const n = list.length + unchecked.length
114
119
  let h = '<h2>Pull requests' + (n ? ' <span class="count">' + n + '</span>' : '') + '</h2>'
115
120
  if (list.length) {
116
- h += '<div class="prlist">' + list.map((p) => {
117
- // 0.9.8: tap the title (or More…) for the same details as the PRs page, in place; GitHub ↗ still goes out
121
+ // 2026-10-29 one compact row each: the state (shape + word), #, the title on one line; tap the title for
122
+ // the repo, branch, checks and the details in place. Copy, GitHub and Unlink stay one tap away.
123
+ h += '<div class="prlist pr-compact">' + list.map((p) => {
118
124
  const isOpen = !!(open && open.has && open.has(p.url))
119
125
  return '<article class="prrow slim' + (isOpen ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
120
- '<div class="pr-line1"><span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) + '</div>' +
121
- '<div class="pr-title"><button type="button" class="linkish pr-title-btn" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + esc(p.title || 'Pull request #' + p.number) + '</button></div>' +
122
- '<div class="pr-meta">' + esc(p.repo || '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + prMoreHtml(p, isOpen) + '</div>' +
123
- (isOpen ? prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
124
- prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) + '</article>'
126
+ '<div class="pr-c1">' + stateHtml(p.state) + '<span class="pr-num">#' + esc(p.number) + '</span>' +
127
+ '<span class="pr-title"><button type="button" class="linkish pr-title-btn" data-tip="' + esc(p.title || 'Pull request #' + p.number) + '" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + esc(p.title || 'Pull request #' + p.number) + '</button></span></div>' +
128
+ (isOpen ? '<div class="pr-meta">' + esc(p.repo || '') + (p.branch ? ' · <span class="mono">' + esc(p.branch) + '</span>' : '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + checksHtml(p.checks) + prMoreHtml(p, isOpen) + '</div>' + prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
129
+ (isOpen ? prActionsHtml(p, { unlinkId: mayEdit ? s.id : null }) : prGhOnlyHtml(p)) + '</article>'
125
130
  }).join('') + '</div>'
126
131
  }
127
- if (unchecked.length) h += '<div class="pr-unlist">' + unchecked.map(uncheckedHtml).join('') + '</div>'
132
+ // several refs that couldn't be checked fold into one "+4 not checked" row
133
+ if (unchecked.length === 1) h += '<div class="pr-unlist">' + uncheckedHtml(unchecked[0]) + '</div>'
134
+ else if (unchecked.length > 1) h += '<details class="pr-unfold"><summary class="pr-unsum">' + STATE_IC.unchecked + '<span>+' + unchecked.length + ' not checked</span></summary><div class="pr-unlist">' + unchecked.map(uncheckedHtml).join('') + '</div></details>'
128
135
  if (!n) h += '<p class="nocp pr-none" data-tip="' + esc(PR_HOW) + '">No PR found for this session' + (noPrWhy(s) ? '<span class="pr-none-why"> · ' + esc(noPrWhy(s)) + '</span>' : '') +
129
136
  (mayEdit ? ' · <label class="linkish pr-none-link" for="' + esc(prInputId(s.id)) + '">Link a PR…</label>' : '') + '</p>'
130
- if (mayEdit) h += '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input id="' + esc(prInputId(s.id)) + '" name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
137
+ const form = '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input id="' + esc(prInputId(s.id)) + '" name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
138
+ // 2026-10-29: with PRs already linked, the box waits behind one quiet "Link another PR…"
139
+ if (mayEdit) h += n ? '<details class="pr-linkd"><summary class="linkish">Link another PR…</summary>' + form + '</details>' : form
131
140
  return h
132
141
  }
133
142
  // 2026-10-28 No PR: one quiet line saying why, then Link a PR… (it focuses the box below).
package/ui/recap-line.js CHANGED
@@ -3,18 +3,21 @@
3
3
  // says where the session is (the daemon drops it once you prompt again).
4
4
  // Before /clear: what this tab was doing before a /clear, with a link to it.
5
5
  // Pure: the board passes its own esc / ago / clock, so this runs under node too.
6
+ // 0.9.19: the words are Claude's, so `code` and **bold** render (escaped first, no links).
7
+ import { mdInline } from './changelog-md.js'
8
+ const md = (t) => mdInline(t, { links: false })
6
9
 
7
10
  export function recapLineHtml(s, { esc, ago = () => '', clock = () => '' }) {
8
11
  const r = s && s.recap
9
12
  if (!r || !r.text) return ''
10
13
  const tip = 'Claude Code’s recap' + (r.at ? ' · ' + ago(r.at) + (clock(r.at) ? ' (' + clock(r.at) + ')' : '') : '')
11
- return '<p class="recap-line" data-tip="' + esc(tip) + '"><span class="rl-k">Recap</span> ' + esc(r.text) + '</p>'
14
+ return '<p class="recap-line" data-tip="' + esc(tip) + '"><span class="rl-k">Recap</span> ' + md(r.text) + '</p>'
12
15
  }
13
16
 
14
17
  export function beforeClearHtml(s, { esc }) {
15
18
  const b = s && s.beforeClear
16
19
  if (!b || !b.goal) return ''
17
20
  const href = b.sessionId ? '#/s/' + encodeURIComponent(b.sessionId) : null
18
- const words = esc(b.goal) + (b.stoppedAt ? ' <span class="bc-stop">· stopped at: ' + esc(b.stoppedAt) + '</span>' : '')
21
+ const words = md(b.goal) + (b.stoppedAt ? ' <span class="bc-stop">· stopped at: ' + md(b.stoppedAt) + '</span>' : '')
19
22
  return '<p class="before-clear"><span class="rl-k">Before /clear</span> ' + (href ? '<a href="' + esc(href) + '" data-tip="The session before /clear">' + words + '</a>' : words) + '</p>'
20
23
  }