@leftoff/cli 0.9.8 → 0.9.9-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/bin/leftoff.js +411 -345
- package/docs/SECURITY.md +15 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +48 -5
- package/mod/hooks/register.js +21 -8
- package/package.json +1 -1
- package/ui/app.css +287 -5
- package/ui/app.js +461 -53
- package/ui/composer.js +51 -1
- package/ui/facts.js +34 -0
- package/ui/icons.js +2 -0
- package/ui/index.html +1 -0
- package/ui/live-status.js +13 -1
- package/ui/live-view.js +164 -9
- package/ui/live-wake.js +1 -1
- package/ui/needs.js +181 -13
- package/ui/start.js +367 -0
package/ui/start.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// 0.9.9 New session (docs/plans/start-from-board.md): pick a Mac, a repo it
|
|
2
|
+
// has run Claude Code in, where (the repo, or a new worktree), and the first
|
|
3
|
+
// message (the Live composer, ui/composer.js); Leftoff starts Claude Code there
|
|
4
|
+
// and the session shows on every board right away. Inline on a phone: plain
|
|
5
|
+
// lists of big buttons, no menus or popovers. Pure HTML builders, shared with
|
|
6
|
+
// the unit tests; app.js wires them.
|
|
7
|
+
import { composerBoxHtml } from './composer.js'
|
|
8
|
+
|
|
9
|
+
const REPOS_SHOWN = 6
|
|
10
|
+
|
|
11
|
+
// At this Mac: its own browser (the daemon says local), or your hosted board
|
|
12
|
+
// open on it through the direct door (local and direct). boardLocal is the
|
|
13
|
+
// board's own read of where it runs; the direct door overrides it.
|
|
14
|
+
export function startViewerLocal(viewer, boardLocal = true) {
|
|
15
|
+
return !!(viewer && viewer.local) && (boardLocal || viewer.direct === true)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The choice the page shows: what you picked, else this Mac (or the first
|
|
19
|
+
// online one), its newest repo, and "In the repo".
|
|
20
|
+
export function startPick(data, ui = {}) {
|
|
21
|
+
const ms = (data && data.machines) || []
|
|
22
|
+
const m = ms.find((x) => x.machine === ui.machine && x.online) || ms.find((x) => x.here && x.online && x.repos.length) || ms.find((x) => x.online && x.repos.length) || ms.find((x) => x.here) || ms[0] || null
|
|
23
|
+
const repos = m ? m.repos : []
|
|
24
|
+
// Other folder…: a folder that Mac hasn't run Claude Code in (ui.custom), picked in the browser
|
|
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
|
+
const repo = repos.find((r) => r.path === ui.repo) || custom || repos[0] || null
|
|
27
|
+
const where = ui.where === 'worktree' && !(repo && repo.git === false) ? 'worktree' : 'repo'
|
|
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 + '/')))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// What happens when you press ↑, in one sentence (or why you can't).
|
|
60
|
+
export function startRule(data, pick, { local = true } = {}) {
|
|
61
|
+
const m = pick.m
|
|
62
|
+
if (!m) return { ok: false, text: 'No Mac to start on.' }
|
|
63
|
+
if (!local && !data.trust) return { ok: false, text: 'Turn on Trust my paired devices on a Mac first (Settings → Privacy & security), so you can answer its prompts from here.' }
|
|
64
|
+
if (!m.online) return { ok: false, text: m.machine + ' is offline right now.' }
|
|
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.' }
|
|
66
|
+
// a folder that Mac hasn't run Claude Code in: its hooks run, so only a person at that Mac says yes
|
|
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.' }
|
|
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.' }
|
|
72
|
+
if (m.here && local) return { ok: true, text: 'It starts right away on this Mac.' }
|
|
73
|
+
if (m.here && m.allowRemote === true) return { ok: true, text: 'It starts right away, and ' + m.machine + ' shows a notification.' }
|
|
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.' }
|
|
75
|
+
return { ok: true, text: 'It starts right away if ' + m.machine + ' allows starting sessions from other devices; otherwise someone there confirms it first.' }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ago = (iso, now) => {
|
|
79
|
+
const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return ''
|
|
80
|
+
const s = Math.max(0, Math.round((now - t) / 1000))
|
|
81
|
+
if (s < 3600) return Math.max(1, Math.round(s / 60)) + 'm ago'
|
|
82
|
+
if (s < 86400) return Math.round(s / 3600) + 'h ago'
|
|
83
|
+
return Math.round(s / 86400) + 'd ago'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
|
|
87
|
+
return '<button type="button" class="start-opt" role="radio" aria-checked="' + on + '" data-action="start-pick" data-k="' + esc(k) + '" data-v="' + esc(v) + '"' + (disabled ? ' disabled' : '') + '>' +
|
|
88
|
+
'<span class="start-opt-main">' + label + '</span>' + (sub ? '<span class="start-opt-sub">' + sub + '</span>' : '') + '</button>'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The waiting line after a start that has to be confirmed at the Mac.
|
|
92
|
+
export function startAskLine(ask, { clock = (x) => x } = {}) {
|
|
93
|
+
if (!ask) return null
|
|
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.' : '') }
|
|
95
|
+
if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
|
|
96
|
+
if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
|
|
97
|
+
if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
|
|
98
|
+
if (ask.state === 'failed') return { tone: 'bad', text: 'Didn’t start on ' + ask.machine + ': ' + (ask.error || 'unknown error') }
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', local = true, now = Date.now(), clock = (x) => x, draft = '', busy = false } = {}) {
|
|
103
|
+
const pick = startPick(data, ui)
|
|
104
|
+
const rule = startRule(data, pick, { local })
|
|
105
|
+
let h = '<a class="back" href="#/">← All sessions</a>' +
|
|
106
|
+
'<div class="pagehead"><div><h1>New session</h1><div class="sub">Leftoff starts Claude Code on the Mac you pick. It shows on every board right away: talk to it in Live, approve its edits and commands in Needs you.</div></div></div>'
|
|
107
|
+
h += '<div class="start" data-start>'
|
|
108
|
+
// Mac
|
|
109
|
+
const ms = (data && data.machines) || []
|
|
110
|
+
h += '<section class="start-sec"><h2 class="start-h">Mac</h2><div class="start-opts" role="radiogroup" aria-label="Mac">' +
|
|
111
|
+
ms.map((x) => opt({ k: 'machine', v: x.machine, on: !!pick.m && x.machine === pick.m.machine, disabled: !x.online, esc,
|
|
112
|
+
label: esc((emoji(x.machine) ? emoji(x.machine) + ' ' : '') + x.machine), sub: esc(x.here ? (local ? 'this Mac' : 'the Mac you’re connected to') : x.online ? 'online' : 'offline') })).join('') +
|
|
113
|
+
'</div></section>'
|
|
114
|
+
// Repo
|
|
115
|
+
const repos = pick.m ? pick.m.repos : []
|
|
116
|
+
const all = !!ui.allRepos || repos.length <= REPOS_SHOWN + 1
|
|
117
|
+
const shown = all ? repos : repos.slice(0, REPOS_SHOWN).concat(pick.repo && repos.indexOf(pick.repo) >= REPOS_SHOWN ? [pick.repo] : [])
|
|
118
|
+
const custom = pick.repo && pick.repo.isNew ? pick.repo : null
|
|
119
|
+
const browsing = !!(pick.m && ui.browse && ui.browse.machine === pick.m.machine)
|
|
120
|
+
h += '<section class="start-sec"><h2 class="start-h">Repo</h2>'
|
|
121
|
+
if (!repos.length && !custom) h += '<p class="start-empty">' + esc(pick.m ? pick.m.machine + ' hasn’t run Claude Code in a repo yet.' : 'No Mac yet.') + '</p>'
|
|
122
|
+
if (pick.m) {
|
|
123
|
+
h += '<div class="start-opts" role="radiogroup" aria-label="Repo">' + shown.map((r) => opt({ k: 'repo', v: r.path, on: !!pick.repo && r.path === pick.repo.path, esc,
|
|
124
|
+
label: esc(r.name) + (r.branch ? ' <span class="start-branch">' + esc(r.branch) + '</span>' : ''), sub: esc(r.path.replace(/^\/Users\/[^/]+/, '~')) + (r.lastAt ? ' · ' + esc(ago(r.lastAt, now)) : '') + (r.git === false ? ' · not a git repo' : '') })).join('') +
|
|
125
|
+
// the folder picked with Other folder… (its name and path go in as text: [data-st], startTexts)
|
|
126
|
+
(custom ? '<button type="button" class="start-opt start-custom" role="radio" aria-checked="true" data-action="start-pick" data-k="repo" data-v="' + esc(custom.path) + '"><span class="start-opt-main"><span data-st="custom-name"></span> <span class="start-badge new">first time here</span>' + (custom.git ? '' : ' <span class="start-badge">not a git repo</span>') + '</span><span class="start-opt-sub" data-st="custom-path"></span></button>' : '') +
|
|
127
|
+
(pick.m.online ? '<button type="button" class="start-opt start-other" data-action="start-browse" aria-expanded="' + browsing + '"' + (browsing ? ' aria-controls="start-browse"' : '') + '><span class="start-opt-main">' + icon('folder') + 'Other folder…</span><span class="start-opt-sub">Browse ' + esc(pick.m.machine) + '’s folders</span></button>' : '') + '</div>'
|
|
128
|
+
if (!all) h += '<button type="button" class="btn start-more" data-action="start-all-repos">Show all ' + repos.length + '</button>'
|
|
129
|
+
if (browsing) h += startBrowseHtml(ui.browse, { esc, icon, machine: pick.m.machine })
|
|
130
|
+
if (repos.length) h += '<p class="start-hint">Listed: repos ' + esc(pick.m.machine) + ' has already run Claude Code in. Other folder… picks any folder in your home there; the first time, ' + (pick.m.here && local ? 'starting it is your yes to its hooks.' : 'someone at ' + esc(pick.m.machine) + ' says yes first.') + '</p>'
|
|
131
|
+
}
|
|
132
|
+
h += '</section>'
|
|
133
|
+
// Where
|
|
134
|
+
h += '<section class="start-sec"><h2 class="start-h">Where</h2><div class="start-opts start-where" role="radiogroup" aria-label="Where">' +
|
|
135
|
+
opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
|
|
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) }) +
|
|
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>'
|
|
143
|
+
// First message
|
|
144
|
+
h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
|
|
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' }) +
|
|
146
|
+
'</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>'
|
|
147
|
+
const line = startAskLine(ui.ask, { clock })
|
|
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>'
|
|
149
|
+
h += '</section></div>'
|
|
150
|
+
return h
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Other folder…: the inline folder browser for one Mac (no popover; full width
|
|
154
|
+
// on a phone). Folder names and paths come from that Mac's disk, so they go in
|
|
155
|
+
// as text afterwards ([data-st], startTexts / fillStartTexts), never as markup.
|
|
156
|
+
// b: { machine, loading, error, data: the GET /v1/start/dirs answer }
|
|
157
|
+
export function startBrowseHtml(b, { esc, icon = () => '', machine = '' } = {}) {
|
|
158
|
+
let h = '<div class="start-browse" id="start-browse" role="region" aria-label="Folders on ' + esc(machine) + '">'
|
|
159
|
+
const d = b && b.data
|
|
160
|
+
// or type one you know (~/src/project, or a full path): the browser goes there; the same rules as walking to it
|
|
161
|
+
const goTo = '<form class="sb-path" data-form="start-browse-path" novalidate><label class="sb-path-l" for="sb-path-in">Go to a folder</label><div class="sb-path-row">' +
|
|
162
|
+
'<input type="text" id="sb-path-in" class="sb-path-in" name="path" placeholder="~/code/web" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go"' + (b && b.pathError ? ' aria-invalid="true" aria-describedby="sb-path-err"' : '') + '>' +
|
|
163
|
+
'<button type="submit" class="btn">Go</button></div>' +
|
|
164
|
+
(b && b.pathError ? '<p class="start-rule bad sb-path-err" id="sb-path-err" role="alert" data-st="path-err"></p>' : '') + '</form>'
|
|
165
|
+
if (!d) h += goTo
|
|
166
|
+
if (d) {
|
|
167
|
+
h += '<nav class="sb-crumbs" aria-label="Folder">' + d.crumbs.map((c, i) => (i ? '<span class="sb-sep" aria-hidden="true">/</span>' : '') +
|
|
168
|
+
'<button type="button" class="sb-crumb" data-action="start-browse-go" data-crumb="' + i + '"' + (i === d.crumbs.length - 1 ? ' aria-current="location"' : '') + ' data-st="crumb-' + i + '"></button>').join('') + '</nav>'
|
|
169
|
+
h += '<div class="sb-here">' +
|
|
170
|
+
(d.parent ? '<button type="button" class="btn sb-up" data-action="start-browse-up" aria-label="Up one folder">' + icon('chevron-left') + 'Up</button>' : '') +
|
|
171
|
+
'<span class="sb-cur"><span class="sb-cur-name" data-st="cur"></span>' + (d.git ? ' <span class="start-badge git">git</span>' : ' <span class="start-badge">not a git repo</span>') + '</span>' +
|
|
172
|
+
'<button type="button" class="btn primary sb-use" data-action="start-browse-use"' + (d.parent ? '' : ' disabled') + '>Use this folder</button></div>'
|
|
173
|
+
h += goTo
|
|
174
|
+
if (d.shortcuts && d.shortcuts.length > 1) h += '<div class="sb-cuts"><span class="sb-cuts-l">Jump to</span>' + d.shortcuts.map((c, i) => '<button type="button" class="sb-cut" data-action="start-browse-go" data-cut="' + i + '" data-st="cut-' + i + '"></button>').join('') + '</div>'
|
|
175
|
+
if (!d.entries.length) h += '<p class="start-empty">No folders in here.</p>'
|
|
176
|
+
else h += '<ul class="sb-list">' + d.entries.map((e, i) => '<li><button type="button" class="sb-dir" data-action="start-browse-go" data-entry="' + i + '">' + icon('folder') + '<span class="sb-name" data-st="entry-' + i + '"></span>' + (e.git ? '<span class="start-badge git">git</span>' : '') + icon('chevron-right', 'ic sb-go') + '</button></li>').join('') + '</ul>'
|
|
177
|
+
if (d.capped) h += '<p class="start-hint">Showing the first ' + esc(String(d.entries.length)) + ' of ' + esc(String(d.total)) + ' folders.</p>'
|
|
178
|
+
}
|
|
179
|
+
if (b && b.loading) h += '<p class="start-hint" role="status">Loading ' + esc(machine) + '’s folders…</p>'
|
|
180
|
+
if (b && b.error) h += '<p class="start-rule bad" role="alert">' + esc(b.error) + '</p>'
|
|
181
|
+
h += '<p class="start-hint">Folders only, by name: hidden ones, node_modules and Library are left out, and so is the synced Leftoff folder.</p>' +
|
|
182
|
+
'<div class="sb-foot"><button type="button" class="btn" data-action="start-browse-close">Cancel</button></div></div>'
|
|
183
|
+
return h
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// What you typed in "Go to a folder", as a path to ask for: no spaces around
|
|
187
|
+
// it, no quotes a copy brought along, a shell's "\ " back to a space, no
|
|
188
|
+
// trailing slash. "~" stays "~"; the Mac resolves it (src/dirs.js checkFolder).
|
|
189
|
+
export function typedPath(v) {
|
|
190
|
+
let t = String(v == null ? '' : v).trim()
|
|
191
|
+
if (/^(['"]).*\1$/.test(t) && t.length >= 2) t = t.slice(1, -1).trim()
|
|
192
|
+
t = t.replace(/\\ /g, ' ')
|
|
193
|
+
if (t.length > 1) t = t.replace(/\/+$/, '') || '/'
|
|
194
|
+
return t
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The text for every [data-st] on the page: names and paths from a Mac's disk.
|
|
198
|
+
export function startTexts(data, ui = {}) {
|
|
199
|
+
const t = {}
|
|
200
|
+
const pick = startPick(data, ui)
|
|
201
|
+
if (pick.repo && pick.repo.isNew) { t['custom-name'] = pick.repo.name || ''; t['custom-path'] = pick.repo.label || pick.repo.path || '' }
|
|
202
|
+
const d = ui.browse && ui.browse.data
|
|
203
|
+
if (d) {
|
|
204
|
+
t.cur = d.label || d.path || ''
|
|
205
|
+
d.crumbs.forEach((c, i) => { t['crumb-' + i] = c.name })
|
|
206
|
+
;(d.shortcuts || []).forEach((c, i) => { t['cut-' + i] = c.label })
|
|
207
|
+
d.entries.forEach((e, i) => { t['entry-' + i] = e.name })
|
|
208
|
+
}
|
|
209
|
+
if (ui.browse && ui.browse.pathError) t['path-err'] = ui.browse.pathError
|
|
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
|
+
}
|
|
214
|
+
return t
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function fillStartTexts(root, texts) {
|
|
218
|
+
if (!root || !root.querySelectorAll) return 0
|
|
219
|
+
let n = 0
|
|
220
|
+
for (const el of root.querySelectorAll('[data-st]')) { el.textContent = texts[el.getAttribute('data-st')] || ''; n++ }
|
|
221
|
+
return n
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The folder a browser click means: { path } from a crumb, a shortcut or an entry.
|
|
225
|
+
export function browseTarget(b, el) {
|
|
226
|
+
const d = b && b.data
|
|
227
|
+
if (!d || !el || !el.dataset) return null
|
|
228
|
+
if (el.dataset.crumb != null) return d.crumbs[Number(el.dataset.crumb)] || null
|
|
229
|
+
if (el.dataset.cut != null) return (d.shortcuts || [])[Number(el.dataset.cut)] || null
|
|
230
|
+
if (el.dataset.entry != null) return d.entries[Number(el.dataset.entry)] || null
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// A request waiting on THIS Mac: its board shows it with Start and Decline
|
|
235
|
+
// (only this Mac's own browser may answer).
|
|
236
|
+
export function startAsksBanner(info, { esc, icon, local = true, clock = (x) => x, busy = new Set() } = {}) {
|
|
237
|
+
const asks = info && Array.isArray(info.pending) ? info.pending : []
|
|
238
|
+
if (!asks.length) return ''
|
|
239
|
+
return asks.map((a) => {
|
|
240
|
+
if (a.kind === 'mode') return modeAskBannerHtml(a, info, { esc, local, clock, busy })
|
|
241
|
+
const b = busy.has(a.id)
|
|
242
|
+
const where = a.firstTime ? '<span data-st="ask-folder-' + esc(a.id) + '"></span>' : esc(a.repo)
|
|
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) + '”' +
|
|
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>' +
|
|
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>' : '') +
|
|
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>'
|
|
249
|
+
}).join('')
|
|
250
|
+
}
|
|
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
|
+
|
|
263
|
+
// Asked once on THIS Mac's board when it pairs a phone or sees a new Mac:
|
|
264
|
+
// may it start sessions here? No is the default (first, and what doing
|
|
265
|
+
// nothing means); Yes turns on the switch, and Trust my paired devices if it's
|
|
266
|
+
// off (the words say so). Never shown off this Mac.
|
|
267
|
+
export function startPairAskHtml(info, { esc, local = true, busy = false } = {}) {
|
|
268
|
+
const a = info && info.pairAsk
|
|
269
|
+
if (!a || !local) return ''
|
|
270
|
+
const mac = info.machine || 'this Mac'
|
|
271
|
+
const who = a.kind === 'mac' ? (a.name || 'your other Mac') : 'your phone'
|
|
272
|
+
const what = a.kind === 'mac' ? 'Let ' + who + ' start sessions here?' : 'Let your phone start sessions here?'
|
|
273
|
+
const yes = 'Yes turns on “Allow starting sessions from other devices” for ' + mac + (info.trust ? '' : ', and Trust my paired devices so you can answer its prompts there') + '. It applies to every paired phone and Mac; change it any time in Settings → Privacy & security.'
|
|
274
|
+
return '<div class="banner info pair-ask" role="region" aria-label="' + esc(what) + '"><span class="grow"><b>' + esc(what) + '</b> ' + esc(a.kind === 'mac' ? who + ' could start Claude Code on ' + mac + ' with nobody here;' : 'It could start Claude Code on ' + mac + ' with nobody here;') + ' its edits and commands still wait for you in Needs you. ' + esc(yes) + '</span>' +
|
|
275
|
+
'<button type="button" class="btn" data-action="pair-ask-no"' + (busy ? ' aria-disabled="true"' : '') + '>No</button>' +
|
|
276
|
+
'<button type="button" class="btn" data-action="pair-ask-yes"' + (busy ? ' aria-disabled="true"' : '') + '>Yes, allow</button></div>'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// The session page's line for a session Leftoff runs: where it runs, who
|
|
280
|
+
// started it, and Stop / Resume. The error (Claude Code's own words) is put
|
|
281
|
+
// in with textContent by app.js ([data-headless-err]).
|
|
282
|
+
export function headlessBarHtml(s, { esc, icon, may = true, busy = null, local = true } = {}) {
|
|
283
|
+
const h = s && s.headless
|
|
284
|
+
if (!h) return ''
|
|
285
|
+
const from = s.startedFrom && s.startedFrom.device ? ' from ' + s.startedFrom.device : ''
|
|
286
|
+
const where = s.machine ? ' on ' + s.machine : ''
|
|
287
|
+
let text, btn = ''
|
|
288
|
+
if (h.state === 'running') { text = 'Running' + where + ', started' + from + ' by Leftoff' + (h.awake ? ' · keeping it awake' : ''); if (may) btn = '<button type="button" class="btn" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' }
|
|
289
|
+
else if (h.state === 'stopping') text = 'Stopping' + where + '…'
|
|
290
|
+
else {
|
|
291
|
+
text = (h.state === 'paused' ? 'Paused' + where + ' (Leftoff restarted)' : h.error ? 'Claude Code stopped' + where : 'Stopped' + where) + (from ? ', started' + from : '')
|
|
292
|
+
if (may) btn = '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
|
|
293
|
+
}
|
|
294
|
+
return '<div class="headless-bar ' + esc(h.state) + '" data-sid="' + esc(s.id) + '"><span class="grow">' + icon('play') + esc(text) + '</span>' + btn + '</div>' +
|
|
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>'
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Settings → Privacy & security: the one switch, on this Mac only.
|
|
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 } = {}) {
|
|
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
|
|
367
|
+
}
|