@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/CHANGELOG.md +26 -0
- package/bin/leftoff.js +411 -345
- package/docs/SECURITY.md +15 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +48 -5
- package/mod/hooks/register.js +21 -8
- package/package.json +1 -1
- package/ui/app.css +287 -5
- package/ui/app.js +461 -53
- package/ui/composer.js +51 -1
- package/ui/facts.js +34 -0
- package/ui/icons.js +2 -0
- package/ui/index.html +1 -0
- package/ui/live-status.js +13 -1
- package/ui/live-view.js +164 -9
- package/ui/live-wake.js +1 -1
- package/ui/needs.js +181 -13
- package/ui/start.js +367 -0
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
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"
|
|
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"
|
|
104
|
-
'<span class="need-kind">' + esc(n.label || KIND_LABEL[n.kind] || '') + '</span>' +
|
|
105
|
-
(n.waitSec != null ? '<span class="need-wait"
|
|
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 + '
|
|
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 +
|
|
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 + '
|
|
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
|
-
|
|
142
|
-
|
|
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"
|
|
195
|
-
: '<span class="chip machine agent"
|
|
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"
|
|
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
|
+
}
|