@leftoff/cli 0.9.9-beta.6 → 0.9.9-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -1
- package/bin/leftoff.js +466 -407
- package/docs/SECURITY.md +2 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +37 -2
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +139 -0
- package/ui/app.js +229 -36
- package/ui/composer.js +12 -0
- package/ui/facts.js +34 -0
- package/ui/icons.js +1 -0
- package/ui/live-status.js +12 -1
- package/ui/live-view.js +65 -9
- package/ui/live-wake.js +1 -1
- package/ui/needs.js +158 -2
- package/ui/start.js +128 -12
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,12 +292,76 @@ 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.
|
|
247
|
-
|
|
314
|
+
// 0.9.9: a setting kept only on one Mac (not in the synced config.json) wears a
|
|
315
|
+
// pill beside its label: "🖥️ studio only", with a styled tip. Synced settings
|
|
316
|
+
// get none, so the pill alone tells you which kind it is.
|
|
317
|
+
export const LOCAL_TIP = 'Kept on this Mac; doesn’t sync.'
|
|
318
|
+
export function localPillHtml({ machine, emoji = '', esc }) {
|
|
319
|
+
return ' <span class="local-pill" data-tip="' + esc(LOCAL_TIP) + '">' + (emoji ? '<span aria-hidden="true">' + esc(emoji) + '</span> ' : '') + esc(machine || 'this Mac') + ' only<span class="sr-only">: ' + esc(LOCAL_TIP) + '</span></span>'
|
|
320
|
+
}
|
|
321
|
+
// Away from that Mac the control is locked, and looks it: a lock, not just grey.
|
|
322
|
+
export function lockedAttrs(local) { return local ? '' : ' disabled aria-disabled="true"' }
|
|
323
|
+
export function lockHtml(local, icon) { return local ? '' : '<span class="lock" aria-hidden="true">' + (icon ? icon('lock') : '🔒') + '</span>' }
|
|
324
|
+
|
|
325
|
+
export function startsSettingHtml(info, { esc, local = true, emoji = '', icon = null } = {}) {
|
|
326
|
+
if (!info) return ''
|
|
327
|
+
const m = esc(info.machine || 'that Mac')
|
|
328
|
+
return '<label for="f-start-allow">Start sessions' + localPillHtml({ machine: info.machine, emoji, esc }) + '</label>' +
|
|
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>' +
|
|
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 } = {}) {
|
|
248
339
|
if (!info) return ''
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
251
367
|
}
|