@leftoff/cli 0.9.8 → 0.9.9-beta.2

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/ui/composer.js CHANGED
@@ -4,7 +4,8 @@
4
4
  // something to send. On a Mac (a fine pointer) Enter sends and Shift+Enter is a
5
5
  // new line; on a phone or tablet Return is a new line and the button sends.
6
6
  // ⌘↵ / Ctrl+↵ sends everywhere. Pure functions here (shared with the unit
7
- // tests); app.js wires them to the page.
7
+ // tests); app.js wires them to the page. composerBoxHtml() is the box's markup,
8
+ // shared by Live and the Needs-you Reply card (ui/needs.js).
8
9
 
9
10
  export const COMPOSER_MAX_LINES = 8
10
11
 
@@ -44,3 +45,40 @@ export function composerHeight({ scrollHeight = 0, lineHeight = 22, padY = 0, ma
44
45
  const want = Math.ceil(Number(scrollHeight) || 0)
45
46
  return { height: Math.max(min, Math.min(want, max)), scroll: want > max + 1 }
46
47
  }
48
+
49
+ // The box's markup (Live and the Needs-you Reply card: one look, one
50
+ // behaviour). Pure: the caller hands in what it knows.
51
+ // submit: the data-submit / data-submit-for pair ('live' | 'reply')
52
+ // sendAction: the ↑'s data-action ('live-send' | 'reply-send')
53
+ // draftKey, draft: the textarea's data-draft key and its text
54
+ // may: answering from this device is allowed (false: everything disabled)
55
+ // busy: a send is in flight (the ↑ stays disabled)
56
+ // placeholder, label: the textarea's placeholder and aria-label
57
+ // inputClass: extra class(es) on the textarea (the app's own hooks)
58
+ // sid: the session; on the box as data-sid only when attach is on (the
59
+ // attachment code finds a session's composer by it)
60
+ // attach: show the 📎 and the hidden file input; atts: its chips ([]),
61
+ // chipHtml: chip -> markup
62
+ // keys: the textarea's aria-keyshortcuts
63
+ // Tooltips are the board's styled data-tip (never title=); aria-label stays.
64
+ export const SEND_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
65
+ export const CLIP_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
66
+ const escA = (v) => String(v == null ? '' : v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
67
+ export function composerBoxHtml(o = {}) {
68
+ const may = o.may !== false
69
+ const atts = o.attach && Array.isArray(o.atts) ? o.atts : []
70
+ const sid = o.sid == null ? '' : String(o.sid)
71
+ const off = !may || o.busy || (isBlank(o.draft) && !atts.length)
72
+ const submit = escA(o.submit || 'live')
73
+ const label = o.label || o.placeholder || 'Message'
74
+ const dis = may ? '' : ' disabled'
75
+ return '<div class="composer' + (o.boxClass ? ' ' + escA(o.boxClass) : '') + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '"' + (o.attach && sid ? ' data-sid="' + escA(sid) + '"' : '') + '>' +
76
+ (o.attach ? '<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(o.chipHtml || (() => '')).join('') + '</div>' : '') +
77
+ '<textarea class="composer-input' + (o.inputClass ? ' ' + escA(o.inputClass) : '') + '" rows="1" maxlength="' + (Number(o.maxlength) || 4000) + '" placeholder="' + escA(o.placeholder || '') + '" aria-label="' + escA(label) + '" autocomplete="off" data-draft="' + escA(o.draftKey || '') + '" data-submit="' + submit + '"' + (o.keys ? ' aria-keyshortcuts="' + escA(o.keys) + '"' : '') + dis + '>' + escA(o.draft || '') + '</textarea>' +
78
+ (o.attach
79
+ // the phone's own picker (Photo Library, Take Photo, Choose File): any file
80
+ ? '<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + escA(sid) + '"' + dis + '>' +
81
+ '<button type="button" class="composer-attach" data-action="attach" data-id="' + escA(sid) + '" aria-label="Attach" data-tip="Attach photos or files"' + dis + '><span class="composer-clip">' + CLIP_IC + '</span></button>'
82
+ : '') +
83
+ '<button type="button" class="composer-send" data-action="' + escA(o.sendAction || 'live-send') + '" data-submit-for="' + submit + '" data-id="' + escA(sid) + '" aria-label="Send" data-tip="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
84
+ }
package/ui/icons.js CHANGED
@@ -46,6 +46,7 @@ export const ICONS = {
46
46
  "archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
47
47
  "bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
48
48
  "circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>",
49
+ "square": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/>",
49
50
  "message-square": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>"
50
51
  }
51
52
 
package/ui/needs.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { mdInline } from './changelog-md.js'
11
11
  import { tipHtml, tipId } from './tips.js'
12
+ import { composerBoxHtml } from './composer.js'
12
13
 
13
14
  const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
14
15
  export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
@@ -41,7 +42,7 @@ export function needsLine(n) {
41
42
  // Dismiss (0.6.5): hides this one need on every board; a new question shows again.
42
43
  export function dismissButton(n) {
43
44
  if (!n || !n.key) return ''
44
- return '<button type="button" class="need-dismiss" data-action="need-dismiss" data-need="' + esc(n.key) + '" aria-label="Dismiss" title="Dismiss: hide this one (a new question shows again)">✕</button>'
45
+ return '<button type="button" class="need-dismiss" data-action="need-dismiss" data-need="' + esc(n.key) + '" aria-label="Dismiss" data-tip="Dismiss: hide this one (a new question shows again)">✕</button>'
45
46
  }
46
47
 
47
48
  const KIND_LABEL = { permission: 'Needs your OK', question: 'Question', terminal: 'Waiting in the terminal', reply: 'Asked you', agent: 'Waiting on you' }
@@ -100,9 +101,9 @@ export function needPanel(n, opts = {}) {
100
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) + '"' : '') + '>'
101
102
  h += '<div class="need-head"><span class="need-dot" aria-hidden="true"></span>' +
102
103
  (opts.compact ? '' : '<a class="need-title" href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' + (s.repoLabel ? '<span class="chip mono">' + esc(s.repoLabel) + '</span>' : '')) +
103
- (n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote" title="answered on that machine, through its Leftoff">⌂ ' + esc(n.peer || s.machine) + '</span>' : '') +
104
+ (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>' : '') +
104
105
  '<span class="need-kind">' + esc(n.label || KIND_LABEL[n.kind] || '') + '</span>' +
105
- (n.waitSec != null ? '<span class="need-wait" title="since ' + esc(n.since || '') + '">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
106
+ (n.waitSec != null ? '<span class="need-wait"' + (n.since ? ' data-tip="since ' + esc(n.since) + '"' : '') + '>' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
106
107
  h += needContextHtml(n, opts) + prCardHtml(n.pr)
107
108
  if (n.kind === 'permission' && n.ask) {
108
109
  const d = n.ask.describe || {}
@@ -112,7 +113,7 @@ export function needPanel(n, opts = {}) {
112
113
  h += '<div class="need-actions">' +
113
114
  '<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
114
115
  alwaysButton(n.ask, dis, { session: s }) +
115
- (n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' title="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
116
+ (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
116
117
  '<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
117
118
  '<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
118
119
  '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny" data-submit-for="deny"' + dis + '>Deny' + kbdHint(opts) + '</button><button type="button" class="btn" data-action="ask-deny-close">Cancel</button></div>'
@@ -123,14 +124,14 @@ export function needPanel(n, opts = {}) {
123
124
  h += '<div class="need-q" data-q="' + qi + '" data-multi="' + (q.multiSelect ? '1' : '') + '">' +
124
125
  '<div class="qtext">' + (q.header ? '<span class="qhead">' + esc(q.header) + '</span>' : '') + esc(q.question) + '</div>'
125
126
  if (q.options.length) {
126
- h += '<div class="opts">' + q.options.map((o) => '<button type="button" class="btn opt" data-action="' + (oneTap ? 'ask-pick-send' : 'ask-pick') + '" data-label="' + esc(o.label) + '" aria-pressed="false"' + dis + (o.description ? ' title="' + esc(o.description) + '"' : '') + '>' + esc(o.label) + (o.description ? '<small>' + esc(o.description) + '</small>' : '') + '</button>').join('') + '</div>'
127
+ h += '<div class="opts">' + q.options.map((o) => '<button type="button" class="btn opt" data-action="' + (oneTap ? 'ask-pick-send' : 'ask-pick') + '" data-label="' + esc(o.label) + '" aria-pressed="false"' + dis + '>' + esc(o.label) + (o.description ? '<small>' + esc(o.description) + '</small>' : '') + '</button>').join('') + '</div>'
127
128
  }
128
129
  h += '<input type="text" class="qother" maxlength="500" placeholder="' + (q.options.length ? 'Or type your own answer' : 'Your answer') + '" value="' + esc(drafts['q:' + askId + ':' + qi] || '') + '" data-draft="q:' + esc(askId) + ':' + qi + '" data-submit="answer" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>'
129
130
  h += '</div>'
130
131
  })
131
132
  h += quickRepliesHtml(n, dis)
132
133
  h += '<div class="need-actions">' + (oneTap ? '' : '') + '<button type="button" class="btn primary" data-action="ask-answer" data-choice="answer" data-submit-for="answer"' + dis + '>' + (oneTap ? 'Send typed answer' : 'Send answers') + kbdHint(opts) + '</button>' +
133
- '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + ' title="Decline to answer; Claude carries on without it">Skip</button>' + go + '</div>'
134
+ '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + ' data-tip="Decline to answer; Claude carries on without it">Skip</button>' + go + '</div>'
134
135
  } else if (n.kind === 'terminal') {
135
136
  h += '<div class="need-what"><span class="verb">Claude Code says</span><span>' + esc(n.message || '') + '</span></div>' +
136
137
  '<div class="need-note">This one can only be answered in the terminal.' + (n.peerNote ? ' ' + esc(n.peerNote) + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
@@ -138,8 +139,12 @@ export function needPanel(n, opts = {}) {
138
139
  h += '<blockquote class="need-question">' + esc(n.question || '') + '</blockquote>'
139
140
  if (n.replyPending) h += '<div class="need-note ok">Reply sent ' + esc(opts.agoFn ? opts.agoFn(n.replyPending.at) : '') + ': “' + esc(n.replyPending.text) + '”. Claude reads it as its next prompt.</div>'
140
141
  if (n.canReply) {
141
- h += '<div class="reply-row"><textarea class="reply-text" rows="2" maxlength="4000" placeholder="Reply to Claude…" data-draft="reply:' + esc(s.id) + '" data-submit="reply" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>' + esc(drafts['reply:' + s.id] || '') + '</textarea>' +
142
- '<button type="button" class="btn primary" data-action="reply-send" data-submit-for="reply"' + dis + '>Send' + kbdHint(opts) + '</button></div>' + quickRepliesHtml(n, dis) + '<div class="need-actions">' + go + '</div>'
142
+ // 0.9.8: the same composer as Live (ui/composer.js): rounded box, round ↑,
143
+ // Enter sends on a Mac (⌘↵ everywhere). A reply is text only (Claude reads
144
+ // it as its next prompt), so there is no 📎 here.
145
+ h += composerBoxHtml({ submit: 'reply', sendAction: 'reply-send', sid: s.id, draftKey: 'reply:' + s.id, draft: drafts['reply:' + s.id] || '',
146
+ may, busy: !!busy, placeholder: 'Reply to Claude…', label: 'Reply to Claude', boxClass: 'reply-composer', inputClass: 'reply-text',
147
+ attach: false, keys: opts.composerKeys || KBD_SUBMIT }) + quickRepliesHtml(n, dis) + '<div class="need-actions">' + go + '</div>'
143
148
  } else {
144
149
  h += '<div class="need-note">' + esc(n.why ? 'No reply box: ' + n.why + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
145
150
  }
@@ -191,8 +196,8 @@ export function agentChipHtml(a = {}, { title } = {}) {
191
196
  const inner = esc(a.icon || '◆') + ' ' + esc(who)
192
197
  const t = esc(title || 'external agent · read-only')
193
198
  const url = httpsOnly(a.url)
194
- return url ? '<a class="chip machine agent" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" title="' + t + '">' + inner + '</a>'
195
- : '<span class="chip machine agent" title="' + t + '">' + inner + '</span>'
199
+ return url ? '<a class="chip machine agent" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="' + t + '">' + inner + '</a>'
200
+ : '<span class="chip machine agent" data-tip="' + t + '">' + inner + '</span>'
196
201
  }
197
202
  // "From Tab · reply by texting Tab", or "From Tab · Read-only here".
198
203
  export function agentReplyNote(a = {}) {
@@ -408,7 +413,7 @@ export function prCardHtml(pr) {
408
413
  const inner = '<span class="pr-top"><span class="pr-num">#' + esc(pr.number) + '</span><span class="pr-state ' + esc(pr.state || 'open') + '">' + (pr.state === 'draft' ? 'Draft' : 'Open') + '</span>' +
409
414
  (ck.summary ? '<span class="pr-checks ' + cls + '">' + (icon ? '<span aria-hidden="true">' + icon + '</span> ' : '') + esc(ck.summary) + '</span>' : '') + '</span>' +
410
415
  '<span class="pr-title">' + esc(pr.title || '') + '</span>'
411
- return url ? '<a class="need-pr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" title="Open the pull request on GitHub">' + inner + '<span class="pr-go" aria-hidden="true">↗</span></a>'
416
+ return url ? '<a class="need-pr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open the pull request on GitHub">' + inner + '<span class="pr-go" aria-hidden="true">↗</span></a>'
412
417
  : '<div class="need-pr">' + inner + '</div>'
413
418
  }
414
419
 
package/ui/start.js ADDED
@@ -0,0 +1,136 @@
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
+ // The choice the page shows: what you picked, else this Mac (or the first
12
+ // online one), its newest repo, and "In the repo".
13
+ export function startPick(data, ui = {}) {
14
+ const ms = (data && data.machines) || []
15
+ 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
16
+ const repos = m ? m.repos : []
17
+ const repo = repos.find((r) => r.path === ui.repo) || repos[0] || null
18
+ return { m, repo, where: ui.where === 'worktree' ? 'worktree' : 'repo' }
19
+ }
20
+
21
+ // What happens when you press ↑, in one sentence (or why you can't).
22
+ export function startRule(data, pick, { local = true } = {}) {
23
+ const m = pick.m
24
+ if (!m) return { ok: false, text: 'No Mac to start on.' }
25
+ 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.' }
26
+ if (!m.online) return { ok: false, text: m.machine + ' is offline right now.' }
27
+ if (!pick.repo) return { ok: false, text: m.machine + ' hasn’t run Claude Code in a repo yet. Open Claude Code there once and it shows up here.' }
28
+ if (m.here && local) return { ok: true, text: 'It starts right away on this Mac.' }
29
+ if (m.here && m.allowRemote === true) return { ok: true, text: 'It starts right away, and ' + m.machine + ' shows a notification.' }
30
+ 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.' }
31
+ return { ok: true, text: 'It starts right away if ' + m.machine + ' allows starting sessions from other devices; otherwise someone there confirms it first.' }
32
+ }
33
+
34
+ const ago = (iso, now) => {
35
+ const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return ''
36
+ const s = Math.max(0, Math.round((now - t) / 1000))
37
+ if (s < 3600) return Math.max(1, Math.round(s / 60)) + 'm ago'
38
+ if (s < 86400) return Math.round(s / 3600) + 'h ago'
39
+ return Math.round(s / 86400) + 'd ago'
40
+ }
41
+
42
+ function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
43
+ 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' : '') + '>' +
44
+ '<span class="start-opt-main">' + label + '</span>' + (sub ? '<span class="start-opt-sub">' + sub + '</span>' : '') + '</button>'
45
+ }
46
+
47
+ // The waiting line after a start that has to be confirmed at the Mac.
48
+ export function startAskLine(ask, { clock = (x) => x } = {}) {
49
+ if (!ask) return null
50
+ 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.' }
51
+ if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
52
+ if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
53
+ if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
54
+ if (ask.state === 'failed') return { tone: 'bad', text: 'Didn’t start on ' + ask.machine + ': ' + (ask.error || 'unknown error') }
55
+ return null
56
+ }
57
+
58
+ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', local = true, now = Date.now(), clock = (x) => x, draft = '', busy = false } = {}) {
59
+ const pick = startPick(data, ui)
60
+ const rule = startRule(data, pick, { local })
61
+ let h = '<a class="back" href="#/">← All sessions</a>' +
62
+ '<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>'
63
+ h += '<div class="start" data-start>'
64
+ // Mac
65
+ const ms = (data && data.machines) || []
66
+ h += '<section class="start-sec"><h2 class="start-h">Mac</h2><div class="start-opts" role="radiogroup" aria-label="Mac">' +
67
+ ms.map((x) => opt({ k: 'machine', v: x.machine, on: !!pick.m && x.machine === pick.m.machine, disabled: !x.online, esc,
68
+ 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('') +
69
+ '</div></section>'
70
+ // Repo
71
+ const repos = pick.m ? pick.m.repos : []
72
+ const all = !!ui.allRepos || repos.length <= REPOS_SHOWN + 1
73
+ const shown = all ? repos : repos.slice(0, REPOS_SHOWN).concat(pick.repo && repos.indexOf(pick.repo) >= REPOS_SHOWN ? [pick.repo] : [])
74
+ h += '<section class="start-sec"><h2 class="start-h">Repo</h2>'
75
+ if (!repos.length) h += '<p class="start-empty">' + esc(pick.m ? pick.m.machine + ' hasn’t run Claude Code in a repo yet.' : 'No Mac yet.') + '</p>'
76
+ else {
77
+ 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,
78
+ 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)) : '') })).join('') + '</div>'
79
+ if (!all) h += '<button type="button" class="btn start-more" data-action="start-all-repos">Show all ' + repos.length + '</button>'
80
+ h += '<p class="start-hint">Only repos ' + esc(pick.m.machine) + ' has already run Claude Code in.</p>'
81
+ }
82
+ h += '</section>'
83
+ // Where
84
+ h += '<section class="start-sec"><h2 class="start-h">Where</h2><div class="start-opts start-where" role="radiogroup" aria-label="Where">' +
85
+ opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
86
+ opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: 'its own branch and folder' }) +
87
+ '</div></section>'
88
+ // First message
89
+ h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
90
+ 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' }) +
91
+ '</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>' +
92
+ '<p class="start-hint">Reads run freely; every edit and command waits for you in Needs you (here, your phone, any board).</p>'
93
+ const line = startAskLine(ui.ask, { clock })
94
+ 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>'
95
+ h += '</section></div>'
96
+ return h
97
+ }
98
+
99
+ // A request waiting on THIS Mac: its board shows it with Start and Decline
100
+ // (only this Mac's own browser may answer).
101
+ export function startAsksBanner(info, { esc, icon, local = true, clock = (x) => x, busy = new Set() } = {}) {
102
+ const asks = info && Array.isArray(info.pending) ? info.pending : []
103
+ if (!asks.length) return ''
104
+ return asks.map((a) => {
105
+ const b = busy.has(a.id)
106
+ return '<div class="banner warn start-ask-banner" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to start a session</b> in ' + esc(a.repo) + (a.where === 'worktree' ? ' (new worktree)' : '') + ' on ' + esc(info.machine || 'this Mac') + ': “' + esc(a.prompt) + '” <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
107
+ (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>' +
108
+ '<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>'
109
+ }).join('')
110
+ }
111
+
112
+ // The session page's line for a session Leftoff runs: where it runs, who
113
+ // started it, and Stop / Resume. The error (Claude Code's own words) is put
114
+ // in with textContent by app.js ([data-headless-err]).
115
+ export function headlessBarHtml(s, { esc, icon, may = true } = {}) {
116
+ const h = s && s.headless
117
+ if (!h) return ''
118
+ const from = s.startedFrom && s.startedFrom.device ? ' from ' + s.startedFrom.device : ''
119
+ const where = s.machine ? ' on ' + s.machine : ''
120
+ let text, btn = ''
121
+ 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>' }
122
+ else if (h.state === 'stopping') text = 'Stopping' + where + '…'
123
+ else {
124
+ text = (h.state === 'paused' ? 'Paused' + where + ' (Leftoff restarted)' : h.error ? 'Claude Code stopped' + where : 'Stopped' + where) + (from ? ', started' + from : '')
125
+ if (may) btn = '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
126
+ }
127
+ return '<div class="headless-bar ' + esc(h.state) + '" data-sid="' + esc(s.id) + '"><span class="grow">' + icon('play') + esc(text) + '</span>' + btn + '</div>' +
128
+ (h.error && h.state !== 'running' ? '<pre class="headless-err" data-headless-err="' + esc(s.id) + '"></pre>' : '')
129
+ }
130
+
131
+ // Settings → Privacy & security: the one switch, on this Mac only.
132
+ export function startsSettingHtml(info, { esc, local = true } = {}) {
133
+ if (!info) return ''
134
+ return '<label for="f-start-allow">Start sessions</label><label class="check"><input id="f-start-allow" type="checkbox"' + (info.allowRemote ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Allow starting sessions from other devices</span></label>' +
135
+ '<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. Kept on this Mac only (it doesn’t sync)' + (local ? '.' : '; change it on ' + esc(info.machine || 'that Mac') + '.') + '</div>'
136
+ }