@leftoff/cli 0.9.8 → 0.9.9-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/needs.js CHANGED
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { mdInline } from './changelog-md.js'
11
11
  import { tipHtml, tipId } from './tips.js'
12
+ import { composerBoxHtml } from './composer.js'
13
+ import { liveBoxKind } from './live-view.js'
12
14
 
13
15
  const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
14
16
  export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
@@ -41,7 +43,7 @@ export function needsLine(n) {
41
43
  // Dismiss (0.6.5): hides this one need on every board; a new question shows again.
42
44
  export function dismissButton(n) {
43
45
  if (!n || !n.key) return ''
44
- return '<button type="button" class="need-dismiss" data-action="need-dismiss" data-need="' + esc(n.key) + '" aria-label="Dismiss" title="Dismiss: hide this one (a new question shows again)">✕</button>'
46
+ return '<button type="button" class="need-dismiss" data-action="need-dismiss" data-need="' + esc(n.key) + '" aria-label="Dismiss" data-tip="Dismiss: hide this one (a new question shows again)">✕</button>'
45
47
  }
46
48
 
47
49
  const KIND_LABEL = { permission: 'Needs your OK', question: 'Question', terminal: 'Waiting in the terminal', reply: 'Asked you', agent: 'Waiting on you' }
@@ -86,6 +88,17 @@ export function alwaysButton(ask, dis = '', opts = {}) {
86
88
  tipHtml({ id, label: 'What “' + label + '” does', html: alwaysTipHtml(ask, opts.session) }) + '</span>'
87
89
  }
88
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
+
89
102
  // opts: { mayAnswer, goButton(session) -> html, drafts: { [key]: text }, pending: { [key]: label }, compact, kbd }
90
103
  export function needPanel(n, opts = {}) {
91
104
  const s = n.session || {}
@@ -97,12 +110,12 @@ export function needPanel(n, opts = {}) {
97
110
  const dis = !may || busy ? ' disabled' : ''
98
111
  if (n.kind === 'agent') return agentNeedPanel(n, s)
99
112
  const go = opts.goButton ? opts.goButton(s) : ''
100
- 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) + '"' : '') + '>'
101
114
  h += '<div class="need-head"><span class="need-dot" aria-hidden="true"></span>' +
102
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>' : '')) +
103
- (n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote" title="answered on that machine, through its Leftoff">⌂ ' + esc(n.peer || s.machine) + '</span>' : '') +
104
- '<span class="need-kind">' + esc(n.label || KIND_LABEL[n.kind] || '') + '</span>' +
105
- (n.waitSec != null ? '<span class="need-wait" title="since ' + esc(n.since || '') + '">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
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>' : '') +
117
+ '<span class="need-kind">' + esc(n.label || KIND_LABEL[n.kind] || '') + '</span>' + (opts.countHtml || '') +
118
+ (n.waitSec != null ? '<span class="need-wait"' + (n.since ? ' data-tip="since ' + esc(n.since) + '"' : '') + '>' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
106
119
  h += needContextHtml(n, opts) + prCardHtml(n.pr)
107
120
  if (n.kind === 'permission' && n.ask) {
108
121
  const d = n.ask.describe || {}
@@ -112,7 +125,8 @@ export function needPanel(n, opts = {}) {
112
125
  h += '<div class="need-actions">' +
113
126
  '<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
114
127
  alwaysButton(n.ask, dis, { session: s }) +
115
- (n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' title="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
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
116
130
  '<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
117
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 + '">' +
118
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>'
@@ -123,14 +137,14 @@ export function needPanel(n, opts = {}) {
123
137
  h += '<div class="need-q" data-q="' + qi + '" data-multi="' + (q.multiSelect ? '1' : '') + '">' +
124
138
  '<div class="qtext">' + (q.header ? '<span class="qhead">' + esc(q.header) + '</span>' : '') + esc(q.question) + '</div>'
125
139
  if (q.options.length) {
126
- h += '<div class="opts">' + q.options.map((o) => '<button type="button" class="btn opt" data-action="' + (oneTap ? 'ask-pick-send' : 'ask-pick') + '" data-label="' + esc(o.label) + '" aria-pressed="false"' + dis + (o.description ? ' title="' + esc(o.description) + '"' : '') + '>' + esc(o.label) + (o.description ? '<small>' + esc(o.description) + '</small>' : '') + '</button>').join('') + '</div>'
140
+ h += '<div class="opts">' + q.options.map((o) => '<button type="button" class="btn opt" data-action="' + (oneTap ? 'ask-pick-send' : 'ask-pick') + '" data-label="' + esc(o.label) + '" aria-pressed="false"' + dis + '>' + esc(o.label) + (o.description ? '<small>' + esc(o.description) + '</small>' : '') + '</button>').join('') + '</div>'
127
141
  }
128
142
  h += '<input type="text" class="qother" maxlength="500" placeholder="' + (q.options.length ? 'Or type your own answer' : 'Your answer') + '" value="' + esc(drafts['q:' + askId + ':' + qi] || '') + '" data-draft="q:' + esc(askId) + ':' + qi + '" data-submit="answer" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>'
129
143
  h += '</div>'
130
144
  })
131
145
  h += quickRepliesHtml(n, dis)
132
146
  h += '<div class="need-actions">' + (oneTap ? '' : '') + '<button type="button" class="btn primary" data-action="ask-answer" data-choice="answer" data-submit-for="answer"' + dis + '>' + (oneTap ? 'Send typed answer' : 'Send answers') + kbdHint(opts) + '</button>' +
133
- '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + ' title="Decline to answer; Claude carries on without it">Skip</button>' + go + '</div>'
147
+ '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + ' data-tip="Decline to answer; Claude carries on without it">Skip</button>' + go + '</div>'
134
148
  } else if (n.kind === 'terminal') {
135
149
  h += '<div class="need-what"><span class="verb">Claude Code says</span><span>' + esc(n.message || '') + '</span></div>' +
136
150
  '<div class="need-note">This one can only be answered in the terminal.' + (n.peerNote ? ' ' + esc(n.peerNote) + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
@@ -138,14 +152,19 @@ export function needPanel(n, opts = {}) {
138
152
  h += '<blockquote class="need-question">' + esc(n.question || '') + '</blockquote>'
139
153
  if (n.replyPending) h += '<div class="need-note ok">Reply sent ' + esc(opts.agoFn ? opts.agoFn(n.replyPending.at) : '') + ': “' + esc(n.replyPending.text) + '”. Claude reads it as its next prompt.</div>'
140
154
  if (n.canReply) {
141
- h += '<div class="reply-row"><textarea class="reply-text" rows="2" maxlength="4000" placeholder="Reply to Claude…" data-draft="reply:' + esc(s.id) + '" data-submit="reply" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>' + esc(drafts['reply:' + s.id] || '') + '</textarea>' +
142
- '<button type="button" class="btn primary" data-action="reply-send" data-submit-for="reply"' + dis + '>Send' + kbdHint(opts) + '</button></div>' + quickRepliesHtml(n, dis) + '<div class="need-actions">' + go + '</div>'
155
+ // 0.9.8: the same composer as Live (ui/composer.js): rounded box, round ↑,
156
+ // Enter sends on a Mac (⌘↵ everywhere). A reply is text only (Claude reads
157
+ // it as its next prompt), so there is no 📎 here.
158
+ h += composerBoxHtml({ submit: 'reply', sendAction: 'reply-send', sid: s.id, draftKey: 'reply:' + s.id, draft: drafts['reply:' + s.id] || '',
159
+ may, busy: !!busy, placeholder: 'Reply to Claude…', label: 'Reply to Claude', boxClass: 'reply-composer', inputClass: 'reply-text',
160
+ attach: false, keys: opts.composerKeys || KBD_SUBMIT }) + quickRepliesHtml(n, dis) + '<div class="need-actions">' + go + '</div>'
143
161
  } else {
144
162
  h += '<div class="need-note">' + esc(n.why ? 'No reply box: ' + n.why + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
145
163
  }
146
164
  }
147
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>'
148
166
  if (busy) h += '<div class="need-note">' + esc(busy) + '</div>'
167
+ if (opts.tail) h += opts.tail
149
168
  return h + '</article>'
150
169
  }
151
170
 
@@ -191,8 +210,8 @@ export function agentChipHtml(a = {}, { title } = {}) {
191
210
  const inner = esc(a.icon || '◆') + ' ' + esc(who)
192
211
  const t = esc(title || 'external agent · read-only')
193
212
  const url = httpsOnly(a.url)
194
- return url ? '<a class="chip machine agent" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" title="' + t + '">' + inner + '</a>'
195
- : '<span class="chip machine agent" title="' + t + '">' + inner + '</span>'
213
+ return url ? '<a class="chip machine agent" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="' + t + '">' + inner + '</a>'
214
+ : '<span class="chip machine agent" data-tip="' + t + '">' + inner + '</span>'
196
215
  }
197
216
  // "From Tab · reply by texting Tab", or "From Tab · Read-only here".
198
217
  export function agentReplyNote(a = {}) {
@@ -408,7 +427,7 @@ export function prCardHtml(pr) {
408
427
  const inner = '<span class="pr-top"><span class="pr-num">#' + esc(pr.number) + '</span><span class="pr-state ' + esc(pr.state || 'open') + '">' + (pr.state === 'draft' ? 'Draft' : 'Open') + '</span>' +
409
428
  (ck.summary ? '<span class="pr-checks ' + cls + '">' + (icon ? '<span aria-hidden="true">' + icon + '</span> ' : '') + esc(ck.summary) + '</span>' : '') + '</span>' +
410
429
  '<span class="pr-title">' + esc(pr.title || '') + '</span>'
411
- return url ? '<a class="need-pr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" title="Open the pull request on GitHub">' + inner + '<span class="pr-go" aria-hidden="true">↗</span></a>'
430
+ return url ? '<a class="need-pr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open the pull request on GitHub">' + inner + '<span class="pr-go" aria-hidden="true">↗</span></a>'
412
431
  : '<div class="need-pr">' + inner + '</div>'
413
432
  }
414
433
 
@@ -458,3 +477,152 @@ export function quickRepliesHtml(n, dis = '') {
458
477
  if (!list.length) return ''
459
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>'
460
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
+ // answered ones carry what folds them (live-view.js slotHtml): their state and one line for a group of one
612
+ return rows.map((r) => ({ key: r.key, at: r.at, html: askRowHtml(r), ...(r.waiting ? {} : { fold: { state: r.state, kind: r.kind, line: askRowLine(r) } }) }))
613
+ }
614
+ // "Asked to run: cd ~/x && ls": the folded row's words when it holds one ask
615
+ export function askRowLine(r) {
616
+ const what = r.kind === 'question' ? (r.waiting ? 'Asks you' : 'Asked you') : askPhrase(r.verb, { done: !r.waiting })
617
+ const d = String(r.detail || '').replace(/\s+/g, ' ').trim()
618
+ return what + (d ? ': ' + (d.length > 160 ? d.slice(0, 159) + '…' : d) : '')
619
+ }
620
+ const TONE = { Waiting: 'wait', Denied: 'no', Skipped: 'no', 'Auto-approved': 'auto' }
621
+ export function askRowHtml(r) {
622
+ const what = r.kind === 'question' ? (r.waiting ? 'Asks you' : 'Asked you') : askPhrase(r.verb, { done: !r.waiting })
623
+ const sig = [r.state, r.detail, r.note].join('|')
624
+ let h32 = 5381; for (let i = 0; i < sig.length; i++) h32 = ((h32 << 5) + h32 + sig.charCodeAt(i)) | 0
625
+ return '<li class="lv-ask t-' + (TONE[r.state] || 'ok') + '" data-key="' + esc(r.key) + '" data-sig="' + (h32 >>> 0).toString(36) + '">' +
626
+ '<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>' +
627
+ '<span class="lva-state">' + esc(r.waiting ? 'Waiting for you' : r.state) + (r.note ? ' ' + esc(r.note) : '') + '</span></li>'
628
+ }