@leftoff/cli 0.9.9-beta.6 → 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-status.js CHANGED
@@ -16,6 +16,9 @@
16
16
  // folded into that turn (folded: the id of the message that owns the turn):
17
17
  // it has no Working bubble of its own, and when the turn ends it is
18
18
  // "Answered with the reply above".
19
+ // 0.9.9: until Claude takes it in (at its next step, or when the turn ends)
20
+ // such a message is Queued (queued: true; cancellable: Leftoff can still take
21
+ // it back). Anything Claude does with it ends that.
19
22
 
20
23
  export const LIVE_QUIET_MS = 3 * 60 * 1000
21
24
  export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
@@ -27,11 +30,15 @@ export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool:
27
30
 
28
31
  // One thing that happened to the message. ev: { type, at, tool?, detail?, asked?, text? }
29
32
  // delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended | folded | answered | lost
33
+ const KEEPS_QUEUED = new Set(['delivered', 'queued', 'ask_done', 'noop'])
30
34
  export function stepStatus(st, ev = {}) {
31
35
  const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
32
36
  const at = ev.at || s.last
33
- if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) return s
37
+ if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) { if (s.queued) { delete s.queued; delete s.cancellable } return s }
38
+ if (s.queued && !KEEPS_QUEUED.has(ev.type)) { delete s.queued; delete s.cancellable }
34
39
  switch (ev.type) {
40
+ case 'queued': if (s.phase === 'sent' || s.phase === 'received') { s.queued = true; s.cancellable = !!ev.cancellable } else return s; break
41
+ case 'unqueued': break
35
42
  case 'delivered': if (s.phase === 'sent') s.phase = 'received'; break
36
43
  case 'failed': if (s.phase === 'sent') { s.phase = 'failed'; s.error = ev.error || null; s.endedAt = at } break
37
44
  case 'started': case 'text': if (s.phase !== 'replied') s.phase = 'working'; if (ev.type === 'text') s.waiting = null; break
@@ -57,12 +64,16 @@ export function stepStatus(st, ev = {}) {
57
64
  return s
58
65
  }
59
66
 
67
+ // Waiting behind Claude's turn, not taken in yet.
68
+ export function isQueued(st) { return !!(st && st.queued && !st.folded && (st.phase === 'sent' || st.phase === 'received')) }
69
+
60
70
  // The line under the message: { label, steps, spin, tone, elapsedMs }
61
71
  export function statusView(st, now = Date.now()) {
62
72
  if (!st) return null
63
73
  const quiet = !st.folded && (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
64
74
  const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
65
75
  let label
76
+ if (isQueued(st)) return { label: 'Queued', steps: [{ name: 'Sent', state: 'done' }, { name: 'Queued', state: 'now' }], spin: false, tone: 'queued', quiet: false, elapsedMs, final: false, queued: true }
66
77
  if (st.phase === 'sent') label = 'Sent, waiting for the session'
67
78
  else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
68
79
  else if (st.phase === 'working' && st.folded) label = 'Taken into the turn above'
package/ui/live-view.js CHANGED
@@ -3,12 +3,29 @@
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 }
10
10
  export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.endedAt && s.live !== 'closed')
11
11
 
12
+ // 0.9.9: which Live box a session's page shows, and so whether it fetches the
13
+ // thread. A session Leftoff started (headless) always has one, whatever its
14
+ // capture says ("hooks": its card came from the daemon before Claude's first
15
+ // hook): its first message and the reply are in it, while it runs and after
16
+ // it stops. Another hooks-only session has no Live at all; any other one has
17
+ // a thread while it is live-linked and the how-to otherwise.
18
+ // -> 'none' | 'thread' | 'readonly' (a stopped headless one: no composer) | 'how'
19
+ export function liveBoxKind(s) {
20
+ if (!s || s.agent || s.codingAgent === 'codex') return 'none'
21
+ if (s.capture === 'hooks' && !s.headless) return 'none'
22
+ if (s.liveLink && s.liveLink.on) return 'thread'
23
+ if (s.headless) return 'readonly'
24
+ if (s.endedAt || s.live === 'closed') return 'none'
25
+ return 'how'
26
+ }
27
+ export const liveThreadWanted = (s) => { const k = liveBoxKind(s); return k === 'thread' || k === 'readonly' }
28
+
12
29
  // The name a session goes by. A live session is never "Untitled": until it has
13
30
  // a title (its first prompt, Claude Code's /rename, a generated one) it goes by
14
31
  // its repo and branch.
@@ -201,7 +218,7 @@ export function messageAttachments(m) {
201
218
  if (!/Attached: /.test(m.text || '')) return { text: m.text || '', attachments: [] }
202
219
  return parseAttachedLines(m.text)
203
220
  }
204
- 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 }) {
205
222
  const who = senderLabel(m)
206
223
  const title = m.from === 'you' ? senderTitle(m) : ''
207
224
  let body
@@ -220,13 +237,16 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
220
237
  if (st && (st.ran || []).length) foot += toolsHtml(st, { esc, now, open: false })
221
238
  const ran = (m.ranWithoutAsking || []).length ? m.ranWithoutAsking : []
222
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 })
223
242
  } else if (m.status) {
224
243
  foot = deliveryHtml(m.status, { esc, error: m.errorText })
225
244
  } else if (m.state) {
226
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>'
227
246
  }
228
- const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '')
229
- 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>'
230
250
  }
231
251
 
232
252
  const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
@@ -242,6 +262,19 @@ export function deliveryHtml(st, { esc, error = null }) {
242
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>'
243
263
  }
244
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
+
245
278
  // "git -C /a/b diff --cached --quiet" → "git -C /a… --cached --quiet": the start and the end stay visible.
246
279
  export function midClip(s, n = 56) {
247
280
  const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
@@ -295,16 +328,39 @@ export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
295
328
  }
296
329
 
297
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".
298
334
  export function threadHtml(msgs, opts) {
299
335
  const list = msgs || []
300
336
  const byId = new Map(list.filter((m) => m.from !== 'claude' && m.id).map((m) => [m.id, m]))
301
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
302
350
  let html = ''
303
- for (const m of list) {
304
- 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 }
305
354
  html += entryHtml(m, opts)
306
- if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') html += pendingClaudeHtml(m, opts)
307
- }
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)
308
364
  return html
309
365
  }
310
366
 
@@ -325,7 +381,7 @@ export { ranWithoutAsking }
325
381
  // A content signature, to replace only the entries that changed (the time-ago
326
382
  // text is left out so a minute ticking over doesn't redraw a reply).
327
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) }
328
- 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]) }
329
385
 
330
386
  // ------------------------------------------------------------ the DOM keeper
331
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
+ }