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