@leftoff/cli 0.9.9-beta.1 → 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 +25 -1
- 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 +270 -0
- package/ui/app.js +442 -31
- package/ui/composer.js +12 -0
- 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 +165 -2
- package/ui/start.js +367 -0
package/ui/composer.js
CHANGED
|
@@ -34,6 +34,18 @@ export function isTouchUi(matchMedia) {
|
|
|
34
34
|
} catch { return false }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// 0.9.9 After a send that went through, a phone's keyboard closes: the text
|
|
38
|
+
// box lets go of focus. On a Mac (a fine pointer) focus stays, so you can keep
|
|
39
|
+
// typing. A send that failed keeps its text and its focus everywhere.
|
|
40
|
+
export function keepFocusAfterSend({ ok = false, touch = false } = {}) { return !ok || !touch }
|
|
41
|
+
// Let go of the focused text box (the on-screen keyboard closes); true if one had it.
|
|
42
|
+
export function letGoOfKeyboard(doc) {
|
|
43
|
+
const a = doc && doc.activeElement
|
|
44
|
+
if (!a || a === doc.body || !/^(TEXTAREA|INPUT)$/.test(String(a.tagName || '').toUpperCase()) || typeof a.blur !== 'function') return false
|
|
45
|
+
a.blur()
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
// The height the box should be for its content: scrollHeight is the
|
|
38
50
|
// textarea's at height:auto (padding included), lineHeight and padY in px.
|
|
39
51
|
// -> { height, scroll } where scroll says the content is taller than the cap.
|
package/ui/facts.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// 0.9.9 the session page's info cards (Machine, Repo, Directory, Session id…).
|
|
2
|
+
// Pure (the unit tests read it). A card whose value is an id or a path is a
|
|
3
|
+
// button: tap or click copies the whole value (the toast says it), the whole
|
|
4
|
+
// value is its data-tip, and it is a 44px target. A long path shows its start
|
|
5
|
+
// and its end, the part that tells worktrees apart.
|
|
6
|
+
import { icon } from './icons.js'
|
|
7
|
+
import { midClip } from './live-view.js'
|
|
8
|
+
|
|
9
|
+
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
10
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
11
|
+
|
|
12
|
+
// factHtml('Turns', '3') or factHtml('Directory', cwd, { copy: cwd, what: 'directory', clip: 34 })
|
|
13
|
+
// copy: the full value to copy (a card without one is plain); what: its name in "Copy <what>"
|
|
14
|
+
// shown: what the card shows when it isn't the copied value itself (the worktree's name for its path)
|
|
15
|
+
// A long path: its start, then its last folder whole ("/Users/you…/add-a-joke-0994").
|
|
16
|
+
export function clipPath(p, n = 34) {
|
|
17
|
+
const t = String(p == null ? '' : p)
|
|
18
|
+
if (t.length <= n) return t
|
|
19
|
+
const i = t.replace(/\/+$/, '').lastIndexOf('/')
|
|
20
|
+
const last = i >= 0 ? t.slice(i + 1) : ''
|
|
21
|
+
if (!last || last.length > n - 8) return midClip(t, n)
|
|
22
|
+
return t.slice(0, n - last.length - 2) + '…/' + last
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function factHtml(k, v, { cls = '', copy = null, what = null, shown = null, clip = 0 } = {}) {
|
|
26
|
+
const value = String(v == null ? '' : v)
|
|
27
|
+
const view = shown != null ? String(shown) : clip ? (value.includes('/') ? clipPath(value, clip) : midClip(value, clip)) : value
|
|
28
|
+
if (copy == null || copy === '') {
|
|
29
|
+
return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v">' + esc(view) + '</div></div>'
|
|
30
|
+
}
|
|
31
|
+
const full = String(copy)
|
|
32
|
+
return '<button type="button" class="fact copyable' + (cls ? ' ' + cls : '') + '" data-action="copy-fact" data-copy="' + esc(full) + '" data-tip="' + esc(full) + '" aria-label="' + esc('Copy ' + (what || k.toLowerCase()) + ': ' + full) + '">' +
|
|
33
|
+
'<span class="k">' + esc(k) + '<span class="fact-ic" aria-hidden="true">' + icon('copy') + '</span></span><span class="v">' + esc(view) + '</span></button>'
|
|
34
|
+
}
|
package/ui/icons.js
CHANGED
|
@@ -17,6 +17,7 @@ export const ICONS = {
|
|
|
17
17
|
"qr-code": "<rect width=\"5\" height=\"5\" x=\"3\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"16\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"3\" y=\"16\" rx=\"1\"/> <path d=\"M21 16h-3a2 2 0 0 0-2 2v3\"/> <path d=\"M21 21v.01\"/> <path d=\"M12 7v3a2 2 0 0 1-2 2H7\"/> <path d=\"M3 12h.01\"/> <path d=\"M12 3h.01\"/> <path d=\"M12 16v.01\"/> <path d=\"M16 12h1\"/> <path d=\"M21 12v.01\"/> <path d=\"M12 21v-1\"/>",
|
|
18
18
|
"refresh-cw": "<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/> <path d=\"M21 3v5h-5\"/> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/> <path d=\"M8 16H3v5\"/>",
|
|
19
19
|
"check": "<path d=\"M20 6 9 17l-5-5\"/>",
|
|
20
|
+
"lock": "<rect width=\"18\" height=\"11\" x=\"3\" y=\"11\" rx=\"2\" ry=\"2\"/> <path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/>",
|
|
20
21
|
"folder-sync": "<path d=\"M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v.5\"/> <path d=\"M12 10v4h4\"/> <path d=\"m12 14 1.535-1.605a5 5 0 0 1 8 1.5\"/> <path d=\"M22 22v-4h-4\"/> <path d=\"m22 18-1.535 1.605a5 5 0 0 1-8-1.5\"/>",
|
|
21
22
|
"bell": "<path d=\"M10.268 21a2 2 0 0 0 3.464 0\"/> <path d=\"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326\"/>",
|
|
22
23
|
"x": "<path d=\"M18 6 6 18\"/> <path d=\"m6 6 12 12\"/>",
|
|
@@ -46,6 +47,7 @@ export const ICONS = {
|
|
|
46
47
|
"archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
|
|
47
48
|
"bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
|
|
48
49
|
"circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>",
|
|
50
|
+
"square": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/>",
|
|
49
51
|
"message-square": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>"
|
|
50
52
|
}
|
|
51
53
|
|
package/ui/index.html
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
</nav>
|
|
37
37
|
<div class="topright">
|
|
38
38
|
<button type="button" class="searchbtn" id="searchBtn" title="Search (⌘K)" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K /"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><span class="sb-lbl">Search</span><kbd class="sb-kbd" id="searchKbd">⌘K</kbd></button>
|
|
39
|
+
<a href="#/new" data-route="new" id="newTab" class="iconbtn newbtn" data-tip="New session" aria-label="New session"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14"/> <path d="M12 5v14"/></svg><span class="lbl">New session</span></a>
|
|
39
40
|
<button type="button" class="health" id="health" title="Status" aria-haspopup="dialog" aria-expanded="false" aria-controls="statusPop"><span class="dot"></span><span class="label">connecting…</span></button>
|
|
40
41
|
<a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" title="What’s new" aria-label="What’s new"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span class="lbl">What’s new</span><span class="newdot" hidden></span></a>
|
|
41
42
|
<a href="#/settings" data-route="settings" id="setTab" class="iconbtn" title="Settings" aria-label="Settings"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span class="lbl">Settings</span><span class="topdot" hidden></span></a>
|
package/ui/live-status.js
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
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; sendNow: Send now can make it run next). Anything Claude does
|
|
22
|
+
// with it ends that.
|
|
19
23
|
|
|
20
24
|
export const LIVE_QUIET_MS = 3 * 60 * 1000
|
|
21
25
|
export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
|
|
@@ -27,11 +31,15 @@ export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool:
|
|
|
27
31
|
|
|
28
32
|
// One thing that happened to the message. ev: { type, at, tool?, detail?, asked?, text? }
|
|
29
33
|
// delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended | folded | answered | lost
|
|
34
|
+
const KEEPS_QUEUED = new Set(['delivered', 'queued', 'ask_done', 'noop'])
|
|
30
35
|
export function stepStatus(st, ev = {}) {
|
|
31
36
|
const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
|
|
32
37
|
const at = ev.at || s.last
|
|
33
|
-
if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) return s
|
|
38
|
+
if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) { if (s.queued) { delete s.queued; delete s.cancellable; delete s.sendNow } return s }
|
|
39
|
+
if (s.queued && !KEEPS_QUEUED.has(ev.type)) { delete s.queued; delete s.cancellable; delete s.sendNow }
|
|
34
40
|
switch (ev.type) {
|
|
41
|
+
case 'queued': if (s.phase === 'sent' || s.phase === 'received') { s.queued = true; s.cancellable = !!ev.cancellable; if (ev.cancellable && ev.sendNow) s.sendNow = true; else delete s.sendNow } else return s; break
|
|
42
|
+
case 'unqueued': break
|
|
35
43
|
case 'delivered': if (s.phase === 'sent') s.phase = 'received'; break
|
|
36
44
|
case 'failed': if (s.phase === 'sent') { s.phase = 'failed'; s.error = ev.error || null; s.endedAt = at } break
|
|
37
45
|
case 'started': case 'text': if (s.phase !== 'replied') s.phase = 'working'; if (ev.type === 'text') s.waiting = null; break
|
|
@@ -57,12 +65,16 @@ export function stepStatus(st, ev = {}) {
|
|
|
57
65
|
return s
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
// Waiting behind Claude's turn, not taken in yet.
|
|
69
|
+
export function isQueued(st) { return !!(st && st.queued && !st.folded && (st.phase === 'sent' || st.phase === 'received')) }
|
|
70
|
+
|
|
60
71
|
// The line under the message: { label, steps, spin, tone, elapsedMs }
|
|
61
72
|
export function statusView(st, now = Date.now()) {
|
|
62
73
|
if (!st) return null
|
|
63
74
|
const quiet = !st.folded && (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
|
|
64
75
|
const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
|
|
65
76
|
let label
|
|
77
|
+
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
78
|
if (st.phase === 'sent') label = 'Sent, waiting for the session'
|
|
67
79
|
else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
|
|
68
80
|
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,14 +218,17 @@ 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
|
|
208
225
|
if (m.from === 'claude') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
|
|
209
226
|
else if (m.from === 'you') {
|
|
210
227
|
const a = messageAttachments(m)
|
|
211
|
-
|
|
228
|
+
// 0.9.9 Queued: a long one folds to two lines; a tap opens it in place
|
|
229
|
+
const fold = isQueued(m.status) && queuedLong(a.text)
|
|
230
|
+
const open = fold ? ' lv-q-long" role="button" tabindex="0" aria-expanded="false" aria-label="Show the whole message" data-action="lv-queued-more' : ''
|
|
231
|
+
body = (a.text || !a.attachments.length ? '<div class="lv-text lv-md' + open + '">' + (md ? md(a.text) : esc(a.text)) + '</div>' : '') + (a.attachments.length && attHtml ? attHtml(a.attachments, m) : '')
|
|
212
232
|
} else if (m.from === 'terminal') body = '<div class="lv-text lv-md">' + (md ? md(m.text || '') : esc(m.text || '')) + '</div>'
|
|
213
233
|
else body = '<div class="lv-text">' + esc(m.text || '') + '</div>'
|
|
214
234
|
let foot = ''
|
|
@@ -220,13 +240,19 @@ export function entryHtml(m, { esc, ago, md, now = Date.now(), status = null, at
|
|
|
220
240
|
if (st && (st.ran || []).length) foot += toolsHtml(st, { esc, now, open: false })
|
|
221
241
|
const ran = (m.ranWithoutAsking || []).length ? m.ranWithoutAsking : []
|
|
222
242
|
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>'
|
|
243
|
+
} else if (m.status && m.from === 'you' && isQueued(m.status)) {
|
|
244
|
+
foot = queuedHtml(m, { esc, may })
|
|
223
245
|
} else if (m.status) {
|
|
224
246
|
foot = deliveryHtml(m.status, { esc, error: m.errorText })
|
|
225
247
|
} else if (m.state) {
|
|
226
248
|
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
249
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
250
|
+
const queued = m.from === 'you' && isQueued(m.status)
|
|
251
|
+
const sig = entrySig(m) + (m.from === 'claude' && status ? statusSig(status) : '') + (queued ? '|q' + (may ? 1 : 0) : '')
|
|
252
|
+
// 0.9.9: a Queued one is compact: no "You · from this Mac 10:04 PM" line
|
|
253
|
+
// (it comes back once Claude takes the message in)
|
|
254
|
+
const head = queued ? '' : '<div class="lv-who"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(who) + ' ' + timeHtml(m.at, { esc, now }) + '</div>'
|
|
255
|
+
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)) + '">' + head + body + foot + '</li>'
|
|
230
256
|
}
|
|
231
257
|
|
|
232
258
|
const FINAL_PHASES = new Set(['replied', 'failed', 'stopped'])
|
|
@@ -242,6 +268,26 @@ export function deliveryHtml(st, { esc, error = null }) {
|
|
|
242
268
|
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
269
|
}
|
|
244
270
|
|
|
271
|
+
// 0.9.9 Under a message still waiting behind Claude's turn, one small row:
|
|
272
|
+
// "◷ Queued · Send now · Edit · Cancel". Send now where Claude Code can make it
|
|
273
|
+
// run next (a session Leftoff runs, a CLI with Send now); Edit and Cancel where
|
|
274
|
+
// Leftoff can truly take it back; and only if you may send from this device.
|
|
275
|
+
// Otherwise the plain fact. Small text buttons, each with a 44px hit area
|
|
276
|
+
// (app.css). No tooltip.
|
|
277
|
+
export function queuedHtml(m, { esc, may = true }) {
|
|
278
|
+
const st = m.status || {}
|
|
279
|
+
const id = esc(m.id || '')
|
|
280
|
+
const dot = '<span class="lv-q-dot" aria-hidden="true">·</span>'
|
|
281
|
+
const btn = (action, label, aria) => dot + '<button type="button" class="lv-q-btn" data-action="' + action + '" data-msg="' + id + '" aria-label="' + aria + '">' + label + '</button>'
|
|
282
|
+
const acts = st.cancellable && may
|
|
283
|
+
? (st.sendNow ? btn('lv-queued-now', 'Send now', 'Send now: Claude stops its current step') : '') +
|
|
284
|
+
btn('lv-queued-edit', 'Edit', 'Edit this queued message') + btn('lv-queued-cancel', 'Cancel', 'Cancel this queued message')
|
|
285
|
+
: dot + '<span class="lv-q-hint">Claude reads it after its current step</span>'
|
|
286
|
+
return '<div class="lv-q" role="status"><span class="lv-q-tag">Queued</span>' + acts + '</div>'
|
|
287
|
+
}
|
|
288
|
+
// A queued message longer than about two lines folds (tap to open in place).
|
|
289
|
+
export function queuedLong(text) { const t = String(text || '').trim(); return t.length > 80 || t.includes('\n') }
|
|
290
|
+
|
|
245
291
|
// "git -C /a/b diff --cached --quiet" → "git -C /a… --cached --quiet": the start and the end stay visible.
|
|
246
292
|
export function midClip(s, n = 56) {
|
|
247
293
|
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
@@ -295,19 +341,125 @@ export function pendingClaudeHtml(you, { esc, ago, now = Date.now() }) {
|
|
|
295
341
|
}
|
|
296
342
|
|
|
297
343
|
// The whole thread: each message you sent, then Claude's side of that turn.
|
|
344
|
+
// opts.inserts (0.9.9): other rows ({ at, html }: the permission asks) go in
|
|
345
|
+
// where they happened, before the first message after them; with earlier
|
|
346
|
+
// messages not shown yet, one from before the first shown waits for "Show earlier".
|
|
347
|
+
// A turn's asks sit before that turn's reply, so the newest reply is the last
|
|
348
|
+
// thing in the thread: the reply entry is made when Claude's first words of
|
|
349
|
+
// the turn land and keeps that time, so its own time can't place them; the
|
|
350
|
+
// turn's span (up to the next message, or the turn's end) does.
|
|
351
|
+
// Answered asks next to each other fold into one row (askGroupHtml);
|
|
352
|
+
// opts.openGroups: the group keys opened on this page.
|
|
298
353
|
export function threadHtml(msgs, opts) {
|
|
299
354
|
const list = msgs || []
|
|
300
355
|
const byId = new Map(list.filter((m) => m.from !== 'claude' && m.id).map((m) => [m.id, m]))
|
|
301
356
|
const answered = new Set(list.filter((m) => m.from === 'claude' && m.replyTo).map((m) => m.replyTo))
|
|
357
|
+
const t = (x) => { const v = typeof x === 'number' ? x : Date.parse(x || ''); return Number.isFinite(v) ? v : null }
|
|
358
|
+
const first = list.length ? t(list[0].at) : null
|
|
359
|
+
const ins = ((opts && opts.inserts) || []).filter((r) => r && r.html)
|
|
360
|
+
.map((r, i) => ({ ...r, t: t(r.at), i }))
|
|
361
|
+
.filter((r) => !(opts.earlier > 0 && first != null && r.t != null && r.t < first))
|
|
362
|
+
.sort((a, b) => ((a.t == null ? Infinity : a.t) - (b.t == null ? Infinity : b.t)) || a.i - b.i)
|
|
363
|
+
let k = 0
|
|
364
|
+
const take = (at) => { const out = []; while (k < ins.length && ins[k].t != null && at != null && ins[k].t < at) out.push(ins[k++]); return out }
|
|
365
|
+
const upTo = (at) => slotHtml(take(at), opts)
|
|
366
|
+
// 0.9.9: messages Queued behind Claude's turn wait at the bottom, in the order
|
|
367
|
+
// sent, with no Working bubble; once Claude takes one in it goes back to its place
|
|
368
|
+
const waiting = list.filter((m) => m.from === 'you' && isQueued(m.status) && !answered.has(m.id))
|
|
369
|
+
const rest = waiting.length ? list.filter((m) => !waiting.includes(m)) : list
|
|
302
370
|
let html = ''
|
|
303
|
-
|
|
304
|
-
|
|
371
|
+
rest.forEach((m, i) => {
|
|
372
|
+
const next = i + 1 < rest.length ? t(rest[i + 1].at) : null
|
|
373
|
+
if (m.from === 'claude') {
|
|
374
|
+
const you = m.replyTo ? byId.get(m.replyTo) : null
|
|
375
|
+
html += upTo(Math.max(t(m.at) == null ? -Infinity : t(m.at), replyBefore(m, you, next, t)))
|
|
376
|
+
html += entryHtml(m, { ...opts, status: you && you.status ? you.status : null })
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
html += upTo(t(m.at))
|
|
305
380
|
html += entryHtml(m, opts)
|
|
306
|
-
if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent')
|
|
381
|
+
if (m.status && !m.status.folded && !answered.has(m.id) && m.status.phase !== 'failed' && m.status.phase !== 'sent') {
|
|
382
|
+
// 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
|
|
383
|
+
html += upTo(next == null ? Infinity : next)
|
|
384
|
+
html += pendingClaudeHtml(m, opts)
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
html += slotHtml(ins.slice(k), opts)
|
|
388
|
+
for (const m of waiting) html += entryHtml(m, opts)
|
|
389
|
+
return html
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Rows from before this time go above Claude's reply m. A reply to a message
|
|
393
|
+
// (yours, or a prompt typed in the terminal) closes its turn: everything up to
|
|
394
|
+
// the next message is that turn's. The last one: up to the turn's end (the
|
|
395
|
+
// board message's endedAt; a transcript reply's own time is when its last
|
|
396
|
+
// words landed), or everything while the turn still runs. A reply with no
|
|
397
|
+
// message of its own (Claude's reply tool) stays where its time puts it.
|
|
398
|
+
const TURN_END_SLACK_MS = 1000
|
|
399
|
+
export function replyBefore(m, you, next, t = (x) => { const v = typeof x === 'number' ? x : Date.parse(x || ''); return Number.isFinite(v) ? v : null }) {
|
|
400
|
+
if (!m || !m.replyTo) return -Infinity
|
|
401
|
+
if (next != null) return next
|
|
402
|
+
const st = you && you.status
|
|
403
|
+
if (st) return st.endedAt ? t(st.endedAt) + TURN_END_SLACK_MS : (m.done === false || !FINAL_PHASES.has(st.phase) ? Infinity : t(m.at) + TURN_END_SLACK_MS)
|
|
404
|
+
const end = t(m.at)
|
|
405
|
+
return end == null ? -Infinity : end + TURN_END_SLACK_MS
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// One place in the thread between two messages: its rows in order, answered
|
|
409
|
+
// asks next to each other folded into one row; a waiting one is never folded.
|
|
410
|
+
export function slotHtml(rows, opts = {}) {
|
|
411
|
+
let html = ''
|
|
412
|
+
let run = []
|
|
413
|
+
const flush = () => { if (run.length) html += askGroupHtml(run, opts); run = [] }
|
|
414
|
+
for (const r of rows || []) {
|
|
415
|
+
if (r.fold) { run.push(r); continue }
|
|
416
|
+
flush()
|
|
417
|
+
html += r.html
|
|
307
418
|
}
|
|
419
|
+
flush()
|
|
308
420
|
return html
|
|
309
421
|
}
|
|
310
422
|
|
|
423
|
+
// "▸ 7 asks · 6 allowed · 1 always allowed": answered asks folded into one
|
|
424
|
+
// compact row; tapping it opens the rows in place (no popover), tapping again
|
|
425
|
+
// folds them. Closed by default, and on every fresh page.
|
|
426
|
+
const FOLD_ORDER = ['Allowed', 'Allowed for this session', 'Always allowed here', 'Auto-approved', 'Answered', 'Denied', 'Skipped']
|
|
427
|
+
const FOLD_WORD = { Allowed: 'allowed', 'Allowed for this session': 'allowed for this session', 'Always allowed here': 'always allowed', 'Auto-approved': 'auto-approved', Answered: 'answered', Denied: 'denied', Skipped: 'skipped' }
|
|
428
|
+
const FOLD_NO = new Set(['Denied', 'Skipped'])
|
|
429
|
+
export function askGroupKey(rows) { return 'askg:' + ((rows && rows[0] && rows[0].key) || '') }
|
|
430
|
+
export function askGroupCounts(rows) {
|
|
431
|
+
const by = new Map()
|
|
432
|
+
for (const r of rows || []) { const st = (r.fold && r.fold.state) || 'Answered'; by.set(st, (by.get(st) || 0) + 1) }
|
|
433
|
+
const keys = [...by.keys()].sort((a, b) => { const ia = FOLD_ORDER.indexOf(a), ib = FOLD_ORDER.indexOf(b); return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) })
|
|
434
|
+
return keys.map((st) => ({ state: st, n: by.get(st), word: FOLD_WORD[st] || st.toLowerCase(), no: FOLD_NO.has(st) }))
|
|
435
|
+
}
|
|
436
|
+
export function askGroupLabel(rows) {
|
|
437
|
+
const n = (rows || []).length
|
|
438
|
+
const noun = (rows || []).every((r) => r.fold && r.fold.kind === 'question') ? (n === 1 ? 'question' : 'questions') : (n === 1 ? 'ask' : 'asks')
|
|
439
|
+
return [n + ' ' + noun, ...askGroupCounts(rows).map((c) => c.n + ' ' + c.word)].join(' · ')
|
|
440
|
+
}
|
|
441
|
+
export function askGroupHtml(rows, { esc = escHtml, openGroups = null } = {}) {
|
|
442
|
+
const key = askGroupKey(rows)
|
|
443
|
+
const open = !!(openGroups && openGroups.has && openGroups.has(key))
|
|
444
|
+
const id = 'lvag-' + key.replace(/[^A-Za-z0-9_-]/g, '')
|
|
445
|
+
let sum
|
|
446
|
+
if (rows.length === 1) {
|
|
447
|
+
const f = rows[0].fold || {}
|
|
448
|
+
const c = askGroupCounts(rows)[0]
|
|
449
|
+
sum = '<span class="lvag-what">' + esc(f.line || '') + '</span><span class="lvag-n' + (c.no ? ' lvag-no' : '') + '">' + esc(f.state || 'Answered') + '</span>'
|
|
450
|
+
} else {
|
|
451
|
+
const n = rows.length
|
|
452
|
+
const noun = rows.every((r) => r.fold && r.fold.kind === 'question') ? 'questions' : 'asks'
|
|
453
|
+
sum = '<span class="lvag-what">' + esc(n + ' ' + noun) + '</span>' + askGroupCounts(rows).map((c) => '<span class="lvag-sep" aria-hidden="true">·</span><span class="lvag-n' + (c.no ? ' lvag-no' : '') + '">' + esc(c.n + ' ' + c.word) + '</span>').join('')
|
|
454
|
+
}
|
|
455
|
+
const sig = sigHash(JSON.stringify([open, rows.map((r) => [r.key, r.html])]))
|
|
456
|
+
return '<li class="lv-askgroup' + (open ? ' open' : '') + '" data-key="' + esc(key) + '" data-sig="' + esc(sig) + '">' +
|
|
457
|
+
'<button type="button" class="lvag-toggle" data-action="ask-group" data-group="' + esc(key) + '" aria-expanded="' + (open ? 'true' : 'false') + '" aria-controls="' + esc(id) + '" aria-label="' + esc(askGroupLabel(rows) + (open ? ', shown' : ', tap to show')) + '">' +
|
|
458
|
+
'<span class="lvag-caret" aria-hidden="true">▸</span>' + sum + '</button>' +
|
|
459
|
+
'<ul class="lvag-list" id="' + esc(id) + '"' + (open ? '' : ' hidden') + '>' + rows.map((r) => r.html).join('') + '</ul></li>'
|
|
460
|
+
}
|
|
461
|
+
const escHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
462
|
+
|
|
311
463
|
export function statusHtml(st, { esc, now = Date.now(), error = null }) {
|
|
312
464
|
const v = statusView(st, now)
|
|
313
465
|
if (!v) return ''
|
|
@@ -325,7 +477,7 @@ export { ranWithoutAsking }
|
|
|
325
477
|
// A content signature, to replace only the entries that changed (the time-ago
|
|
326
478
|
// text is left out so a minute ticking over doesn't redraw a reply).
|
|
327
479
|
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]) }
|
|
480
|
+
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.status.sendNow], m.done, m.ranWithoutAsking, m.errorText]) }
|
|
329
481
|
|
|
330
482
|
// ------------------------------------------------------------ the DOM keeper
|
|
331
483
|
|
|
@@ -376,7 +528,10 @@ export function createLiveKeeper() {
|
|
|
376
528
|
// Claude's "Working" bubble isn't news by itself; the answer that replaces it is
|
|
377
529
|
if (!cur) { if (seenKept && !li.dataset.older) { if (!li.dataset.pending) added++ } else older++; prev ? prev.after(li) : oldOl.prepend(li); prev = li; continue }
|
|
378
530
|
if (!cur.dataset.older) seenKept = true
|
|
531
|
+
const before = prev
|
|
379
532
|
if (cur.dataset.sig !== li.dataset.sig) { if (cur.dataset.pending && !li.dataset.pending) added++; cur.replaceWith(li); prev = li } else prev = cur
|
|
533
|
+
// the same order as a fresh page: a row that moved (a turn's asks go before its reply) moves here too
|
|
534
|
+
if (before ? before.nextElementSibling !== prev : oldOl.firstElementChild !== prev) { if (before) before.after(prev); else oldOl.prepend(prev) }
|
|
380
535
|
}
|
|
381
536
|
for (const li of have.values()) li.remove()
|
|
382
537
|
const d = scrollDecision({ wasAtBottom, newCount: added, unseen: kept.unseen })
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
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,152 @@ 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
|
+
// 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
|
+
}
|