@leftoff/cli 0.9.9-beta.7 → 0.9.9-beta.8

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/live-view.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // thread survive the board's re-renders: the same list element stays on the
4
4
  // page, messages are diffed in, and it sticks to the bottom only when you are
5
5
  // already there.
6
- import { statusView, elapsedText, ranWithoutAsking } from './live-status.js'
6
+ import { statusView, elapsedText, ranWithoutAsking, isQueued } from './live-status.js'
7
7
  import { parseAttachedLines } from './attach.js'
8
8
 
9
9
  const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
@@ -218,7 +218,7 @@ export function messageAttachments(m) {
218
218
  if (!/Attached: /.test(m.text || '')) return { text: m.text || '', attachments: [] }
219
219
  return parseAttachedLines(m.text)
220
220
  }
221
- export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, attHtml = null }) {
221
+ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, attHtml = null, may = true }) {
222
222
  const who = senderLabel(m)
223
223
  const title = m.from === 'you' ? senderTitle(m) : ''
224
224
  let body
@@ -237,13 +237,16 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
237
237
  if (st && (st.ran || []).length) foot += toolsHtml(st, { esc, now, open: false })
238
238
  const ran = (m.ranWithoutAsking || []).length ? m.ranWithoutAsking : []
239
239
  if (ran.length) foot += '<div class="lv-note">Ran without asking: ' + esc(ran.join(', ')) + ' <span class="faint">(read-only or allowed in your Claude Code settings, so no Allow/Deny)</span></div>'
240
+ } else if (m.status && m.from === 'you' && isQueued(m.status)) {
241
+ foot = queuedHtml(m, { esc, may })
240
242
  } else if (m.status) {
241
243
  foot = deliveryHtml(m.status, { esc, error: m.errorText })
242
244
  } else if (m.state) {
243
245
  foot = '<div class="lv-tick ' + (m.state === 'failed' ? 'bad' : '') + '">' + esc(m.state === 'failed' ? (m.errorText || 'Didn’t arrive') : m.state === 'received' ? 'Received ✓✓' : 'Sent ✓') + '</div>'
244
246
  }
245
- const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '')
246
- return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '"><div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' ' + timeHtml(m.at, { esc, now }) + '</div>' + body + foot + '</li>'
247
+ const queued = m.from === 'you' && isQueued(m.status)
248
+ const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '') + (queued ? '|q' + (may ? 1 : 0) : '')
249
+ return '<li class="lv lv-' + esc(m.from) + (m.history ? ' lv-history' : '') + (queued ? ' lv-queued' : '') + '" data-key="' + esc(entryKey(m)) + '" data-sig="' + esc(sigHash(sig)) + '"><div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' ' + timeHtml(m.at, { esc, now }) + '</div>' + body + foot + '</li>'
247
250
  }
248
251
 
249
252
  const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
@@ -259,6 +262,19 @@ export function deliveryHtml(st, { esc, error = null }) {
259
262
  return '<div class="lv-tick' + (got ? ' got' : '') + '" title="' + esc(got ? 'Claude Code received it' : 'Waiting for the session to pick it up') + '">' + (got ? 'Received <span aria-hidden="true">✓✓</span>' : 'Sent <span aria-hidden="true">✓</span>') + '</div>'
260
263
  }
261
264
 
265
+ // 0.9.9 Under a message still waiting behind Claude's turn: "Queued", then
266
+ // Edit and Cancel where Leftoff can truly take it back (a session Leftoff runs,
267
+ // and you may send from this device), else the plain fact. No tooltip.
268
+ export function queuedHtml(m, { esc, may = true }) {
269
+ const st = m.status || {}
270
+ const id = esc(m.id || '')
271
+ const acts = st.cancellable && may
272
+ ? '<span class="lv-q-acts"><button type="button" class="btn lv-q-btn" data-action="lv-queued-edit" data-msg="' + id + '" aria-label="Edit this queued message">Edit</button>' +
273
+ '<button type="button" class="btn lv-q-btn" data-action="lv-queued-cancel" data-msg="' + id + '" aria-label="Cancel this queued message">Cancel</button></span>'
274
+ : '<span class="lv-q-hint">Claude reads it after its current step</span>'
275
+ return '<div class="lv-q" role="status"><span class="lv-q-tag">Queued</span>' + acts + '</div>'
276
+ }
277
+
262
278
  // "git -C /a/b diff --cached --quiet" → "git -C /a… --cached --quiet": the start and the end stay visible.
263
279
  export function midClip(s, n = 56) {
264
280
  const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
@@ -312,16 +328,39 @@ export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
312
328
  }
313
329
 
314
330
  // The whole thread: each message you sent, then Claude's side of that turn.
331
+ // opts.inserts (0.9.9): other rows ({ at, html }: the permission asks) go in
332
+ // where they happened, before the first message after them; with earlier
333
+ // messages not shown yet, one from before the first shown waits for "Show earlier".
315
334
  export function threadHtml(msgs, opts) {
316
335
  const list = msgs || []
317
336
  const byId = new Map(list.filter((m) => m.from !== 'claude' && m.id).map((m) => [m.id, m]))
318
337
  const answered = new Set(list.filter((m) => m.from === 'claude' && m.replyTo).map((m) => m.replyTo))
338
+ const t = (x) => { const v = Date.parse(x || ''); return Number.isFinite(v) ? v : null }
339
+ const first = list.length ? t(list[0].at) : null
340
+ const ins = ((opts && opts.inserts) || []).filter((r) => r && r.html)
341
+ .map((r, i) => ({ ...r, t: t(r.at), i }))
342
+ .filter((r) => !(opts.earlier > 0 && first != null && r.t != null && r.t < first))
343
+ .sort((a, b) => ((a.t == null ? Infinity : a.t) - (b.t == null ? Infinity : b.t)) || a.i - b.i)
344
+ let k = 0
345
+ const upTo = (at) => { let h = ''; while (k < ins.length && ins[k].t != null && at != null && ins[k].t < at) h += ins[k++].html; return h }
346
+ // 0.9.9: messages Queued behind Claude's turn wait at the bottom, in the order
347
+ // sent, with no Working bubble; once Claude takes one in it goes back to its place
348
+ const waiting = list.filter((m) => m.from === 'you' && isQueued(m.status) && !answered.has(m.id))
349
+ const rest = waiting.length ? list.filter((m) => !waiting.includes(m)) : list
319
350
  let html = ''
320
- for (const m of list) {
321
- if (m.from === 'claude') { const you = m.replyTo ? byId.get(m.replyTo) : null; html += entryHtml(m, { ...opts, status: you && you.status ? you.status : null }); continue }
351
+ rest.forEach((m, i) => {
352
+ html += upTo(t(m.at))
353
+ if (m.from === 'claude') { const you = m.replyTo ? byId.get(m.replyTo) : null; html += entryHtml(m, { ...opts, status: you && you.status ? you.status : null }); return }
322
354
  html += entryHtml(m, opts)
323
- if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
324
- }
355
+ if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') {
356
+ // the asks of this turn go before Claude's Working bubble: the answer that replaces it in place then lands after them, as on a fresh page
357
+ const next = i + 1 < rest.length ? t(rest[i + 1].at) : null
358
+ html += upTo(next == null ? Infinity : next)
359
+ html += pendingClaudeHtml(m, opts)
360
+ }
361
+ })
362
+ while (k < ins.length) html += ins[k++].html
363
+ for (const m of waiting) html += entryHtml(m, opts)
325
364
  return html
326
365
  }
327
366
 
@@ -342,7 +381,7 @@ export { ranWithoutAsking }
342
381
  // A content signature, to replace only the entries that changed (the time-ago
343
382
  // text is left out so a minute ticking over doesn't redraw a reply).
344
383
  function sigHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
345
- export function entrySig(m) { return JSON.stringify([m.text, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null], m.done, m.ranWithoutAsking, m.errorText]) }
384
+ export function entrySig(m) { return JSON.stringify([m.text, (m.attachments || []).map((a) => a.id), m.state, m.status && [m.status.phase, m.status.tool, m.status.waiting, (m.status.ran || []).length, m.status.endedAt, m.status.folded || null, !!m.status.queued, !!m.status.cancellable], m.done, m.ranWithoutAsking, m.errorText]) }
346
385
 
347
386
  // ------------------------------------------------------------ the DOM keeper
348
387
 
package/ui/live-wake.js CHANGED
@@ -55,7 +55,7 @@ export function backoffMs(attempt) {
55
55
  export function liveSignature(msgs) {
56
56
  return (msgs || []).map((m) => {
57
57
  const st = m.status || {}
58
- return [m.id || m.key || '', m.from || '', st.phase || '', st.tool ? st.tool.name : '', st.waiting ? 'w' : '', st.endedAt || '', m.text ? String(m.text).length : 0].join(':')
58
+ return [m.id || m.key || '', m.from || '', st.phase || '', st.tool ? st.tool.name : '', st.waiting ? 'w' : '', st.endedAt || '', m.text ? String(m.text).length : 0, st.queued ? (st.cancellable ? 'qc' : 'q') : ''].join(':')
59
59
  }).join('|')
60
60
  }
61
61
 
package/ui/needs.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { mdInline } from './changelog-md.js'
11
11
  import { tipHtml, tipId } from './tips.js'
12
12
  import { composerBoxHtml } from './composer.js'
13
+ import { liveBoxKind } from './live-view.js'
13
14
 
14
15
  const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
15
16
  export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
@@ -87,6 +88,17 @@ export function alwaysButton(ask, dis = '', opts = {}) {
87
88
  tipHtml({ id, label: 'What “' + label + '” does', html: alwaysTipHtml(ask, opts.session) }) + '</span>'
88
89
  }
89
90
 
91
+ // 0.9.9 "Auto-approve the rest": only for a running session Leftoff started
92
+ // (it can switch Claude Code's mode mid-session, src/headless.js setMode).
93
+ // Allows this call, then switches the session to Auto (from another device:
94
+ // the same rule as starting one there; else that Mac says yes first).
95
+ export const AUTO_REST_TIP = 'Allows this, then switches this session to Auto: routine steps just run; riskier ones get a safety check that lets them run or blocks them. Back to Ask me on the session page.'
96
+ export function autoRestButton(s, dis = '') {
97
+ const h = s && s.headless
98
+ if (!h || h.state !== 'running' || h.mode === 'auto') return ''
99
+ return '<button type="button" class="btn auto-rest" data-action="ask-auto"' + dis + ' aria-label="Auto-approve the rest of this session" data-tip="' + esc(AUTO_REST_TIP) + '">Auto-approve the rest</button>'
100
+ }
101
+
90
102
  // opts: { mayAnswer, goButton(session) -> html, drafts: { [key]: text }, pending: { [key]: label }, compact, kbd }
91
103
  export function needPanel(n, opts = {}) {
92
104
  const s = n.session || {}
@@ -98,11 +110,11 @@ export function needPanel(n, opts = {}) {
98
110
  const dis = !may || busy ? ' disabled' : ''
99
111
  if (n.kind === 'agent') return agentNeedPanel(n, s)
100
112
  const go = opts.goButton ? opts.goButton(s) : ''
101
- let h = '<article class="need k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '"' + (n.key ? ' data-need="' + esc(n.key) + '"' : '') + '>'
113
+ let h = '<article class="need ' + (opts.dockClass ? esc(opts.dockClass) + ' ' : '') + 'k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '"' + (n.key ? ' data-need="' + esc(n.key) + '"' : '') + '>'
102
114
  h += '<div class="need-head"><span class="need-dot" aria-hidden="true"></span>' +
103
115
  (opts.compact ? '' : '<a class="need-title" href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' + (s.repoLabel ? '<span class="chip mono">' + esc(s.repoLabel) + '</span>' : '')) +
104
116
  (n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote" data-tip="answered on that machine, through its Leftoff">⌂ ' + esc(n.peer || s.machine) + '</span>' : '') +
105
- '<span class="need-kind">' + esc(n.label || KIND_LABEL[n.kind] || '') + '</span>' +
117
+ '<span class="need-kind">' + esc(n.label || KIND_LABEL[n.kind] || '') + '</span>' + (opts.countHtml || '') +
106
118
  (n.waitSec != null ? '<span class="need-wait"' + (n.since ? ' data-tip="since ' + esc(n.since) + '"' : '') + '>' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
107
119
  h += needContextHtml(n, opts) + prCardHtml(n.pr)
108
120
  if (n.kind === 'permission' && n.ask) {
@@ -114,6 +126,7 @@ export function needPanel(n, opts = {}) {
114
126
  '<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
115
127
  alwaysButton(n.ask, dis, { session: s }) +
116
128
  (n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' data-tip="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>') + // 0.9.8: Live link allows one call at a time
129
+ autoRestButton(s, dis) + // 0.9.9: a session Leftoff runs: allow this, and switch it to Auto
117
130
  '<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
118
131
  '<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
119
132
  '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny" data-submit-for="deny"' + dis + '>Deny' + kbdHint(opts) + '</button><button type="button" class="btn" data-action="ask-deny-close">Cancel</button></div>'
@@ -151,6 +164,7 @@ export function needPanel(n, opts = {}) {
151
164
  }
152
165
  if (!may && n.kind !== 'terminal') h += '<div class="need-note warn">Answering from this device is off. Turn it on in Settings → Privacy & security on the machine running Leftoff, or use Go there.</div>'
153
166
  if (busy) h += '<div class="need-note">' + esc(busy) + '</div>'
167
+ if (opts.tail) h += opts.tail
154
168
  return h + '</article>'
155
169
  }
156
170
 
@@ -463,3 +477,145 @@ export function quickRepliesHtml(n, dis = '') {
463
477
  if (!list.length) return ''
464
478
  return '<div class="quick-replies" role="group" aria-label="Quick replies">' + list.map((t) => '<button type="button" class="qr" data-action="quick-reply" data-text="' + esc(t) + '"' + dis + '>' + esc(t) + '</button>').join('') + '</div>'
465
479
  }
480
+
481
+ // ------------------------------------------------------------ the ask in Live (0.9.9)
482
+ // On a session page with a Live composer, a waiting permission prompt or
483
+ // question takes the composer's place at the bottom of Live (phone first: the
484
+ // buttons are where the thumb and the keyboard were), one at a time ("1 of 3"),
485
+ // and each ask also gets a short row in the thread where it happened, which
486
+ // stays as history once it is answered. "Reply instead" brings the composer
487
+ // back for a waiting ask (app.js keeps that per ask, on this page only).
488
+
489
+ // Does this session's page dock its ask in Live (instead of the Needs-you strip above it)?
490
+ export function askDocked(s) {
491
+ const n = s && s.needsYou
492
+ if (!n || !n.ask || (n.kind !== 'permission' && n.kind !== 'question')) return false
493
+ return liveBoxKind(s) === 'thread'
494
+ }
495
+
496
+ // What sits at the bottom of Live: 'dock' (the ask, in the composer's place),
497
+ // 'slim' (you chose Reply instead: the composer, with one line about the ask
498
+ // above it) or 'composer'. reply: the ask ids you chose to reply to instead.
499
+ export function askBottom(s, reply = null) {
500
+ if (!askDocked(s)) return 'composer'
501
+ return reply && reply.has && reply.has(s.needsYou.ask.id) ? 'slim' : 'dock'
502
+ }
503
+
504
+ // "Run" -> "Wants to run" (waiting) / "Asked to run" (answered)
505
+ export function askPhrase(verb, { done = false } = {}) {
506
+ const v = String(verb || '').trim()
507
+ if (!v) return done ? 'Asked to use a tool' : 'Wants to use a tool'
508
+ return (done ? 'Asked to ' : 'Wants to ') + v.charAt(0).toLowerCase() + v.slice(1)
509
+ }
510
+
511
+ // A command or path long enough to fold to three lines, with "Show all" to open it in place.
512
+ export const ASK_LONG_CHARS = 110
513
+ export const askIsLong = (detail) => { const t = String(detail || ''); return t.length > ASK_LONG_CHARS || t.split('\n').length > 3 }
514
+
515
+ // "1 of 3" when more than one is waiting (answered in order: the next one shows when this one is answered).
516
+ export function askCountHtml(n) {
517
+ const more = Number(n && n.more) || 0
518
+ if (more < 1) return ''
519
+ return '<span class="ask-count" aria-label="' + esc('1 of ' + (more + 1) + ' waiting') + '">1 of ' + esc(String(more + 1)) + '</span>'
520
+ }
521
+
522
+ const replyInsteadHtml = (askId) => '<button type="button" class="ask-reply-instead" data-action="ask-reply-instead" data-ask="' + esc(askId) + '">Reply instead</button>'
523
+
524
+ // The card in the composer's place. opts: needPanel's, plus askOpen (Set of ask ids shown in full).
525
+ export function askDockHtml(n, opts = {}) {
526
+ const s = n.session || {}
527
+ const askId = n.ask ? n.ask.id : ''
528
+ // a question: Needs you's own card (options, Other…, Send / Skip), in the dock
529
+ if (n.kind !== 'permission') return needPanel(n, { ...opts, compact: true, dockClass: 'ask-dock', countHtml: askCountHtml(n), tail: replyInsteadHtml(askId) })
530
+ const may = opts.mayAnswer !== false
531
+ const key = askId
532
+ const busy = opts.pending && opts.pending[key]
533
+ const dis = !may || busy ? ' disabled' : ''
534
+ const drafts = opts.drafts || {}
535
+ const d = n.ask.describe || {}
536
+ const detail = String(d.detail || '')
537
+ const long = askIsLong(detail)
538
+ const open = !!(opts.askOpen && opts.askOpen.has && opts.askOpen.has(askId))
539
+ const did = 'ask-detail-' + String(askId).replace(/[^A-Za-z0-9_-]/g, '')
540
+ const go0 = opts.goButton ? opts.goButton(s) : ''
541
+ const go = /\sdisabled[\s>=]/.test(go0) ? '' : go0 // a Go that can't go is a full row of nothing on a phone
542
+ let h = '<article class="need ask-dock k-permission' + (n.blocking ? ' blocking' : '') + '" data-id="' + esc(s.id) + '" data-ask="' + esc(askId) + '" data-key="' + esc(key) + '"' + (n.key ? ' data-need="' + esc(n.key) + '"' : '') + ' aria-label="Claude needs your OK">'
543
+ h += '<div class="need-head"><span class="need-dot" aria-hidden="true"></span>' +
544
+ (n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote" data-tip="answered on that machine, through its Leftoff">⌂ ' + esc(n.peer || s.machine) + '</span>' : '') +
545
+ '<span class="need-kind">' + esc(n.label || KIND_LABEL.permission) + '</span>' + askCountHtml(n) +
546
+ (n.waitSec != null ? '<span class="need-wait">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + '</div>'
547
+ h += '<div class="ask-what"><div class="ask-verb">' + esc(askPhrase(d.verb || ('Use ' + (n.ask.tool || 'a tool')))) + (detail ? ':' : '') + '</div>' + (d.note ? '<div class="ask-note">' + esc(d.note) + '</div>' : '') +
548
+ (detail ? '<code class="ask-detail' + (long ? ' long' : '') + (open ? ' open' : '') + '" id="' + esc(did) + '">' + esc(detail) + '</code>' : '') +
549
+ (long ? '<button type="button" class="ask-more" data-action="ask-expand" data-ask="' + esc(askId) + '" aria-expanded="' + (open ? 'true' : 'false') + '" aria-controls="' + esc(did) + '">' + (open ? 'Show less' : 'Show all') + '</button>' : '') + '</div>'
550
+ h += '<div class="ask-actions">' +
551
+ '<button type="button" class="btn allow ask-allow" data-action="ask-answer" data-choice="allow"' + dis + ' aria-label="Allow once">Allow</button>' +
552
+ '<button type="button" class="btn deny ask-deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' +
553
+ autoRestButton(s, dis) +
554
+ alwaysButton(n.ask, dis, { session: s, scope: 'dock' }) +
555
+ (n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' data-tip="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>') +
556
+ go + '</div>'
557
+ h += '<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
558
+ '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny" data-submit-for="deny"' + dis + '>Deny</button><button type="button" class="btn" data-action="ask-deny-close">Cancel</button></div>'
559
+ if (!may) h += '<div class="need-note warn">Answering from this device is off. Turn it on in Settings → Privacy & security on the machine running Leftoff, or use Go there.</div>'
560
+ if (busy) h += '<div class="need-note">' + esc(busy) + '</div>'
561
+ return h + replyInsteadHtml(askId) + '</article>'
562
+ }
563
+
564
+ // After "Reply instead": the composer is back, with one line above it that
565
+ // says what is still waiting and brings the card back.
566
+ export function askSlimHtml(n) {
567
+ const d = (n && n.ask && n.ask.describe) || {}
568
+ const what = n.kind === 'permission' ? askPhrase(d.verb || ('Use ' + ((n.ask && n.ask.tool) || 'a tool'))) + (d.detail ? ': ' + d.detail : '') : 'Asked: ' + ((((n.ask && n.ask.questions) || [])[0] || {}).question || 'a question')
569
+ return '<div class="ask-slim" data-ask="' + esc(n.ask ? n.ask.id : '') + '"><span class="need-dot" aria-hidden="true"></span><span class="ask-slim-t">' + esc(what) + '</span>' +
570
+ '<button type="button" class="btn ask-back" data-action="ask-back" data-ask="' + esc(n.ask ? n.ask.id : '') + '">Answer' + (Number(n.more) > 0 ? ' (' + esc(String(Number(n.more) + 1)) + ')' : '') + '</button></div>'
571
+ }
572
+
573
+ // The thread's rows: every ask of this session the board knows, waiting (the
574
+ // card and the ones behind it) or answered from the board (the session's
575
+ // answers list), each at the time Claude asked. -> [{ key, at, html }]
576
+ const STATE_LABEL = { allow: 'Allowed', allow_session: 'Allowed for this session', allow_always: 'Always allowed here', deny: 'Denied', answer: 'Answered' }
577
+ export function askRowState(a) {
578
+ if (!a) return 'Waiting'
579
+ if (a.auto) return 'Auto-approved'
580
+ if (a.kind === 'question' && a.action === 'deny') return 'Skipped'
581
+ return STATE_LABEL[a.action] || 'Answered'
582
+ }
583
+ function splitWhat(what) {
584
+ const t = String(what || '')
585
+ const i = t.indexOf(': ')
586
+ return i > 0 && i < 40 ? { verb: t.slice(0, i), detail: t.slice(i + 2) } : { verb: '', detail: t }
587
+ }
588
+ export function askRows(s) {
589
+ if (!s) return []
590
+ const rows = []
591
+ const seen = new Set()
592
+ const n = s.needsYou
593
+ const waiting = []
594
+ if (n && n.ask && (n.kind === 'permission' || n.kind === 'question')) {
595
+ waiting.push({ id: n.ask.id, kind: n.kind, since: n.since, describe: n.ask.describe || null, question: n.kind === 'question' ? (((n.ask.questions || [])[0] || {}).question || '') : null, first: true })
596
+ for (const q of n.queue || []) waiting.push(q)
597
+ }
598
+ for (const w of waiting) {
599
+ if (!w || !w.id || seen.has(w.id)) continue
600
+ seen.add(w.id)
601
+ rows.push({ key: 'ask:' + w.id, at: w.since || null, waiting: true, kind: w.kind, verb: w.describe ? w.describe.verb : '', detail: w.kind === 'question' ? w.question : (w.describe ? w.describe.detail : ''), state: 'Waiting' })
602
+ }
603
+ for (const a of s.answers || []) {
604
+ if (!a || (a.kind !== 'permission' && a.kind !== 'question')) continue
605
+ const id = a.askId || null
606
+ if (id && seen.has(id)) continue
607
+ if (id) seen.add(id)
608
+ const w = a.kind === 'question' ? { verb: '', detail: a.what || '' } : splitWhat(a.what)
609
+ rows.push({ key: 'ask:' + (id || 'a' + String(a.at || '')), at: a.askedAt || a.at || null, waiting: false, kind: a.kind, verb: w.verb, detail: w.detail, state: askRowState(a), note: a.action === 'deny' && a.summary && /“/.test(a.summary) ? a.summary.replace(/^Denied\s*/, '') : '' })
610
+ }
611
+ return rows.map((r) => ({ key: r.key, at: r.at, html: askRowHtml(r) }))
612
+ }
613
+ const TONE = { Waiting: 'wait', Denied: 'no', Skipped: 'no', 'Auto-approved': 'auto' }
614
+ export function askRowHtml(r) {
615
+ const what = r.kind === 'question' ? (r.waiting ? 'Asks you' : 'Asked you') : askPhrase(r.verb, { done: !r.waiting })
616
+ const sig = [r.state, r.detail, r.note].join('|')
617
+ let h32 = 5381; for (let i = 0; i < sig.length; i++) h32 = ((h32 << 5) + h32 + sig.charCodeAt(i)) | 0
618
+ return '<li class="lv-ask t-' + (TONE[r.state] || 'ok') + '" data-key="' + esc(r.key) + '" data-sig="' + (h32 >>> 0).toString(36) + '">' +
619
+ '<span class="lva-what"><span class="lva-verb">' + esc(what) + (r.detail ? ':' : '') + '</span>' + (r.detail ? ' <code>' + esc(String(r.detail).slice(0, 300)) + '</code>' : '') + '</span>' +
620
+ '<span class="lva-state">' + esc(r.waiting ? 'Waiting for you' : r.state) + (r.note ? ' ' + esc(r.note) : '') + '</span></li>'
621
+ }
package/ui/start.js CHANGED
@@ -25,7 +25,35 @@ export function startPick(data, ui = {}) {
25
25
  const custom = m && ui.custom && ui.custom.machine === m.machine && ui.custom.path === ui.repo && !repos.some((r) => r.path === ui.custom.path) ? { ...ui.custom, isNew: true } : null
26
26
  const repo = repos.find((r) => r.path === ui.repo) || custom || repos[0] || null
27
27
  const where = ui.where === 'worktree' && !(repo && repo.git === false) ? 'worktree' : 'repo'
28
- return { m, repo, where }
28
+ // Permissions: what you picked, else what this board last used for that Mac (kept on the Mac you're connected to), else Ask me
29
+ const mode = START_MODES.some((x) => x.v === ui.mode) ? ui.mode : m && START_MODES.some((x) => x.v === m.lastMode) ? m.lastMode : 'default'
30
+ return { m, repo, where, mode }
31
+ }
32
+
33
+ // 0.9.9 Permissions for a session Leftoff starts (src/claude-headless.js
34
+ // ALLOWED_MODES, checked against Claude Code 2.1.300). Never "skip all asks"
35
+ // (bypassPermissions): too much to hand a start from a phone. A session in
36
+ // your own terminal keeps its own mode (Shift+Tab there).
37
+ export const START_MODES = Object.freeze([
38
+ { v: 'default', label: 'Ask me', sub: 'every edit and command', text: 'Reads run freely; every edit and command waits for you in Needs you (here, your phone, any board).' },
39
+ { v: 'acceptEdits', label: 'Accept edits', sub: 'edits go through, commands ask', text: 'File edits in the folder go through without asking (so do simple file commands like mkdir); other commands still wait for you in Needs you.' },
40
+ { v: 'auto', label: 'Auto', sub: 'a safety check decides', text: 'Claude Code’s auto mode: routine steps just run. Riskier ones get a separate safety check (a little extra usage) that lets them run or blocks them; a blocked step is skipped and Claude says why. Few or no asks.' },
41
+ ])
42
+ export const TERMINAL_MODE_HINT = 'Sessions you run in your own terminal aren’t changed: press Shift+Tab in Claude Code there.'
43
+ export function modeLabel(mode) { const x = START_MODES.find((y) => y.v === (mode === 'manual' ? 'default' : mode)); return x ? x.label : mode === 'plan' ? 'Plan' : String(mode || 'Ask me') }
44
+
45
+ // The session page's small pill beside Live: the mode Claude Code reports.
46
+ export function modePillHtml(s, { esc }) {
47
+ const h = s && s.headless
48
+ if (!h || !h.mode) return ''
49
+ const m = h.mode
50
+ 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>'
51
+ }
52
+
53
+ // Is a path inside one of a Mac's approved folders (its real paths)?
54
+ export function underApproved(p, approved) {
55
+ const x = String(p || '')
56
+ return !!x && (Array.isArray(approved) ? approved : []).some((a) => typeof a === 'string' && a && (x === a || x.startsWith(a.endsWith('/') ? a : a + '/')))
29
57
  }
30
58
 
31
59
  // What happens when you press ↑, in one sentence (or why you can't).
@@ -37,7 +65,10 @@ export function startRule(data, pick, { local = true } = {}) {
37
65
  if (!pick.repo) return { ok: false, text: m.machine + ' hasn’t run Claude Code in a repo yet. Pick Other folder…, or open Claude Code there once and it shows up here.' }
38
66
  // a folder that Mac hasn't run Claude Code in: its hooks run, so only a person at that Mac says yes
39
67
  if (pick.repo.isNew && m.here && local) return { ok: true, text: 'First time Claude Code runs in this folder on this Mac: it starts right away and runs the folder’s hooks and MCP servers.' }
40
- if (pick.repo.isNew) return { ok: true, text: 'First time in this folder on ' + m.machine + ', so someone there confirms it first (it runs the folder’s hooks), even with “Allow starting sessions from other devices” on.' }
68
+ // …unless it's inside a folder approved on that Mac and its switch is on (that Mac decides; this is what it told us)
69
+ if (pick.repo.isNew && m.here && m.allowRemote === true && underApproved(pick.repo.path, m.approved)) return { ok: true, text: 'First time in this folder, but it’s inside a folder approved on ' + m.machine + ': it starts right away, and ' + m.machine + ' shows a notification.' }
70
+ if (pick.repo.isNew && m.here) return { ok: true, text: 'First time in this folder on ' + m.machine + ', so someone there confirms it first (it runs the folder’s hooks). They can approve the folder above it there, so the next ones start right away.' }
71
+ if (pick.repo.isNew) return { ok: true, text: 'First time in this folder on ' + m.machine + ', so someone there confirms it first (it runs the folder’s hooks), unless it’s inside a folder approved there.' }
41
72
  if (m.here && local) return { ok: true, text: 'It starts right away on this Mac.' }
42
73
  if (m.here && m.allowRemote === true) return { ok: true, text: 'It starts right away, and ' + m.machine + ' shows a notification.' }
43
74
  if (m.here) return { ok: true, text: 'Someone at ' + m.machine + ' confirms it first (up to 5 minutes). To skip that, turn on “Allow starting sessions from other devices” in its Settings.' }
@@ -60,7 +91,7 @@ function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
60
91
  // The waiting line after a start that has to be confirmed at the Mac.
61
92
  export function startAskLine(ask, { clock = (x) => x } = {}) {
62
93
  if (!ask) return null
63
- 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 always asks there.' : '') }
94
+ 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.' : '') }
64
95
  if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
65
96
  if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
66
97
  if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
@@ -104,11 +135,15 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
104
135
  opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
105
136
  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) }) +
106
137
  '</div></section>'
138
+ // Permissions (0.9.9)
139
+ const mo = START_MODES.find((x) => x.v === pick.mode) || START_MODES[0]
140
+ h += '<section class="start-sec"><h2 class="start-h">Permissions</h2><div class="start-opts start-mode" role="radiogroup" aria-label="Permissions">' +
141
+ 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('') +
142
+ '</div><p class="start-hint start-mode-hint">' + esc(mo.text) + ' ' + esc(TERMINAL_MODE_HINT) + '</p></section>'
107
143
  // First message
108
144
  h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
109
145
  composerBoxHtml({ submit: 'start', sendAction: 'start-send', draftKey: 'start', draft, may: rule.ok && !ui.ask, busy, placeholder: 'What should Claude do?', label: 'First message', inputClass: 'start-text', keys: 'Enter Meta+Enter Control+Enter' }) +
110
- '</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>' +
111
- '<p class="start-hint">Reads run freely; every edit and command waits for you in Needs you (here, your phone, any board).</p>'
146
+ '</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>'
112
147
  const line = startAskLine(ui.ask, { clock })
113
148
  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>'
114
149
  h += '</section></div>'
@@ -172,7 +207,10 @@ export function startTexts(data, ui = {}) {
172
207
  d.entries.forEach((e, i) => { t['entry-' + i] = e.name })
173
208
  }
174
209
  if (ui.browse && ui.browse.pathError) t['path-err'] = ui.browse.pathError
175
- for (const a of (data && Array.isArray(data.pending) ? data.pending : [])) if (a.firstTime) t['ask-folder-' + a.id] = a.folder || ''
210
+ for (const a of (data && Array.isArray(data.pending) ? data.pending : [])) {
211
+ if (a.firstTime) t['ask-folder-' + a.id] = a.folder || ''
212
+ if (a.firstTime && a.approve) t['ask-approve-' + a.id] = a.approve.label || ''
213
+ }
176
214
  return t
177
215
  }
178
216
 
@@ -199,15 +237,29 @@ export function startAsksBanner(info, { esc, icon, local = true, clock = (x) =>
199
237
  const asks = info && Array.isArray(info.pending) ? info.pending : []
200
238
  if (!asks.length) return ''
201
239
  return asks.map((a) => {
240
+ if (a.kind === 'mode') return modeAskBannerHtml(a, info, { esc, local, clock, busy })
202
241
  const b = busy.has(a.id)
203
242
  const where = a.firstTime ? '<span data-st="ask-folder-' + esc(a.id) + '"></span>' : esc(a.repo)
204
- return '<div class="banner warn start-ask-banner' + (a.firstTime ? ' first' : '') + '" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to start a session</b> in ' + where + (a.where === 'worktree' ? ' (new worktree)' : '') + ' on ' + esc(info.machine || 'this Mac') + ': “' + esc(a.prompt) + '”' +
243
+ return '<div class="banner warn start-ask-banner' + (a.firstTime ? ' first' : '') + '" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to start a session</b> in ' + where + (a.where === 'worktree' ? ' (new worktree)' : '') + (a.mode && a.mode !== 'default' ? ' in <b>' + esc(modeLabel(a.mode)) + '</b>' : '') + ' on ' + esc(info.machine || 'this Mac') + ': “' + esc(a.prompt) + '”' +
205
244
  (a.firstTime ? ' <b class="start-first">First time in this folder; this runs its hooks.</b>' : '') + ' <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
206
245
  (local ? '<button type="button" class="btn primary" data-action="start-ask-confirm" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>' + icon('play') + (b ? 'Starting…' : 'Start it') + '</button>' +
246
+ // a first time: approve the folder above it here, and start this one (the folder is text, put in by fillStartTexts)
247
+ (a.firstTime && a.approve ? '<button type="button" class="btn approve" data-action="start-ask-approve" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>Always allow in <span class="mono" data-st="ask-approve-' + esc(a.id) + '"></span></button>' : '') +
207
248
  '<button type="button" class="btn" data-action="start-ask-decline" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>Decline</button>' : '<span class="rs-m">Someone at that Mac confirms it there.</span>') + '</div>'
208
249
  }).join('')
209
250
  }
210
251
 
252
+ // A phone or another Mac asks to switch a running session to Accept edits or
253
+ // Auto while "Allow starting sessions from other devices" is off: the same
254
+ // yes at this Mac a start needs (src/session-start.js switchMode).
255
+ export function modeAskBannerHtml(a, info, { esc, local = true, clock = (x) => x, busy = new Set() } = {}) {
256
+ const b = busy.has(a.id)
257
+ const mo = START_MODES.find((x) => x.v === a.mode)
258
+ return '<div class="banner warn start-ask-banner mode-ask" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to switch ' + esc(a.repo || 'a session') + ' to ' + esc(modeLabel(a.mode)) + '</b> on ' + esc(info.machine || 'this Mac') + '. ' + esc(mo ? mo.text : '') + ' <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
259
+ (local ? '<button type="button" class="btn primary" data-action="start-ask-confirm" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>' + (b ? 'Switching…' : 'Switch it') + '</button>' +
260
+ '<button type="button" class="btn" data-action="start-ask-decline" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>Decline</button>' : '<span class="rs-m">Someone at that Mac confirms it there.</span>') + '</div>'
261
+ }
262
+
211
263
  // Asked once on THIS Mac's board when it pairs a phone or sees a new Mac:
212
264
  // may it start sessions here? No is the default (first, and what doing
213
265
  // nothing means); Yes turns on the switch, and Trust my paired devices if it's
@@ -227,7 +279,7 @@ export function startPairAskHtml(info, { esc, local = true, busy = false } = {})
227
279
  // The session page's line for a session Leftoff runs: where it runs, who
228
280
  // started it, and Stop / Resume. The error (Claude Code's own words) is put
229
281
  // in with textContent by app.js ([data-headless-err]).
230
- export function headlessBarHtml(s, { esc, icon, may = true } = {}) {
282
+ export function headlessBarHtml(s, { esc, icon, may = true, busy = null, local = true } = {}) {
231
283
  const h = s && s.headless
232
284
  if (!h) return ''
233
285
  const from = s.startedFrom && s.startedFrom.device ? ' from ' + s.startedFrom.device : ''
@@ -240,7 +292,22 @@ export function headlessBarHtml(s, { esc, icon, may = true } = {}) {
240
292
  if (may) btn = '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
241
293
  }
242
294
  return '<div class="headless-bar ' + esc(h.state) + '" data-sid="' + esc(s.id) + '"><span class="grow">' + icon('play') + esc(text) + '</span>' + btn + '</div>' +
243
- (h.error && h.state !== 'running' ? '<pre class="headless-err" data-headless-err="' + esc(s.id) + '"></pre>' : '')
295
+ (h.error && h.state !== 'running' ? '<pre class="headless-err" data-headless-err="' + esc(s.id) + '"></pre>' : '') +
296
+ (h.state !== 'stopping' ? headlessModeHtml(s, { esc, may, busy, local }) : '')
297
+ }
298
+
299
+ // The session page's Permissions switch for a session Leftoff runs: the same
300
+ // three as New session. Running, it switches Claude Code mid-session; stopped,
301
+ // it's the mode Resume starts it in.
302
+ export function headlessModeHtml(s, { esc, may = true, busy = null, local = true } = {}) {
303
+ const h = s && s.headless
304
+ if (!h || !may) return '' // can't change it from here (no Trust): the pill beside Live still says which
305
+ const cur = h.mode === 'manual' ? 'default' : h.mode || 'default'
306
+ const mo = START_MODES.find((x) => x.v === cur)
307
+ const remote = !local || s.isLocal === false
308
+ return '<div class="headless-mode" role="group" aria-label="Permissions"><span class="hm-l">Permissions</span><div class="hm-seg" role="radiogroup" aria-label="Permissions">' +
309
+ START_MODES.map((x) => '<button type="button" class="hm-opt" role="radio" aria-checked="' + (x.v === cur) + '" data-action="headless-mode" data-id="' + esc(s.id) + '" data-mode="' + esc(x.v) + '"' + (busy ? ' disabled' : '') + ' data-tip="' + esc(x.text) + '">' + esc(x.label) + (busy === x.v ? '…' : '') + '</button>').join('') +
310
+ '</div><p class="hint hm-hint">' + esc(mo ? mo.text : 'Claude Code is in ' + modeLabel(cur) + ' mode.') + (h.state === 'running' ? '' : ' Applies when you Resume it.') + ' ' + (remote ? 'From another device, Accept edits and Auto follow that Mac’s “Allow starting sessions from other devices” (else someone there says yes). ' : '') + esc(TERMINAL_MODE_HINT) + '</p></div>'
244
311
  }
245
312
 
246
313
  // Settings → Privacy & security: the one switch, on this Mac only.
@@ -260,6 +327,41 @@ export function startsSettingHtml(info, { esc, local = true, emoji = '', icon =
260
327
  const m = esc(info.machine || 'that Mac')
261
328
  return '<label for="f-start-allow">Start sessions' + localPillHtml({ machine: info.machine, emoji, esc }) + '</label>' +
262
329
  '<label class="check' + (local ? '' : ' locked') + '"><input id="f-start-allow" type="checkbox"' + (info.allowRemote ? ' checked' : '') + lockedAttrs(local) + '>' + lockHtml(local, icon) + '<span>Allow starting sessions from other devices</span></label>' +
263
- '<div class="hint">Off: a session your phone or another Mac starts here waits until someone at this Mac confirms it. On: it starts right away, and this Mac shows a notification each time. Needs Trust my paired devices.</div>' +
264
- '<div class="hint local-hint">' + (local ? 'Or run <code>leftoff starts on</code> on this Mac.' : 'Change on ' + m + ', or run <code>leftoff starts on</code> there.') + '</div>'
330
+ '<div class="hint">Off: a session your phone or another Mac starts here waits until someone at this Mac confirms it. On: it starts right away, and this Mac shows a notification each time. A folder this Mac hasn’t run Claude Code in still asks here first, unless it’s inside an approved folder. Needs Trust my paired devices.</div>' +
331
+ '<div class="hint local-hint">' + (local ? 'Or run <code>leftoff starts on</code> on this Mac.' : 'Change on ' + m + ', or run <code>leftoff starts on</code> there.') + '</div>' +
332
+ approvedSettingHtml(info, { esc, local, emoji, icon })
333
+ }
334
+
335
+ // Approved folders (0.9.9): with the switch on, a first-time folder inside one
336
+ // starts from your other devices without asking here. Kept on this Mac; added
337
+ // and removed only at it. Every path is put in as text by fillApproved.
338
+ export function approvedSettingHtml(info, { esc, local = true, emoji = '', icon = null } = {}) {
339
+ if (!info) return ''
340
+ const list = Array.isArray(info.approved) ? info.approved : []
341
+ const m = esc(info.machine || 'that Mac')
342
+ return '<label for="f-start-approve">Approved folders' + localPillHtml({ machine: info.machine, emoji, esc }) + '</label>' +
343
+ '<div class="approved' + (local ? '' : ' locked') + '" data-approved>' +
344
+ (list.length ? '<ul class="approved-list">' + list.map((a, i) => '<li><span class="approved-path mono" data-apt="row-' + i + '"></span>' +
345
+ (local ? '<button type="button" class="btn approved-x" data-action="start-approved-remove" data-i="' + i + '" data-apt-label="row-' + i + '">' + (icon ? icon('x') : '✕') + '<span>Remove</span></button>' : '') + '</li>').join('') + '</ul>'
346
+ : '<div class="approved-none">None yet.</div>') +
347
+ '<div class="approved-add">' + lockHtml(local, icon) + '<input id="f-start-approve" type="text" inputmode="url" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="~/src" aria-describedby="f-start-approve-err"' + lockedAttrs(local) + '>' +
348
+ '<button type="button" class="btn" data-action="start-approved-add"' + lockedAttrs(local) + '>Add</button></div>' +
349
+ '<div class="approved-err" id="f-start-approve-err" role="alert" data-apt="err" hidden></div>' +
350
+ '</div>' +
351
+ '<div class="hint">With the switch on, a folder this Mac hasn’t run Claude Code in starts right away from your other devices when it’s inside one of these. Folders in your home, not home itself.' + (local ? '' : ' Change on ' + m + ', or run <code>leftoff starts allow ~/src</code> there.') + '</div>'
352
+ }
353
+
354
+ // The paths, as text (never markup), and each Remove's name for a screen reader.
355
+ export function fillApproved(root, info, err = '') {
356
+ if (!root || !root.querySelectorAll) return 0
357
+ const list = info && Array.isArray(info.approved) ? info.approved : []
358
+ let n = 0
359
+ for (const el of root.querySelectorAll('[data-apt]')) {
360
+ const k = el.getAttribute('data-apt')
361
+ if (k === 'err') { el.textContent = err || ''; el.hidden = !err; n++; continue }
362
+ const a = list[Number(k.replace('row-', ''))]
363
+ el.textContent = a ? a.label || a.path : ''; n++
364
+ }
365
+ for (const b of root.querySelectorAll('[data-apt-label]')) { const a = list[Number(b.getAttribute('data-apt-label').replace('row-', ''))]; if (a) b.setAttribute('aria-label', 'Remove ' + (a.label || a.path)) }
366
+ return n
265
367
  }