@leftoff/cli 0.6.3 → 0.6.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 +47 -0
- package/README.md +2 -1
- package/bin/leftoff.js +329 -313
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +122 -5
- package/mod/hooks/register.js +31 -8
- package/package.json +1 -1
- package/ui/app.css +102 -3
- package/ui/app.js +355 -63
- package/ui/needs.js +237 -5
- package/ui/relay-client.js +7 -1
- package/ui/repos.js +11 -2
- package/ui/settings-nav.js +3 -3
package/ui/needs.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// the terminal can answer: Go), reply (the turn ended on a question: a reply box
|
|
8
8
|
// when the mod can type it in). Nothing is ever answered without a click.
|
|
9
9
|
|
|
10
|
+
import { mdInline } from './changelog-md.js'
|
|
11
|
+
|
|
10
12
|
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
11
13
|
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
12
14
|
|
|
@@ -35,6 +37,12 @@ export function needsLine(n) {
|
|
|
35
37
|
return n.question || ''
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
// Dismiss (0.6.5): hides this one need on every board; a new question shows again.
|
|
41
|
+
export function dismissButton(n) {
|
|
42
|
+
if (!n || !n.key) return ''
|
|
43
|
+
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>'
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
const KIND_LABEL = { permission: 'Needs your OK', question: 'Question', terminal: 'Waiting in the terminal', reply: 'Asked you', agent: 'Waiting on you' }
|
|
39
47
|
|
|
40
48
|
export function cardBadge(n) {
|
|
@@ -59,12 +67,13 @@ export function needPanel(n, opts = {}) {
|
|
|
59
67
|
const dis = !may || busy ? ' disabled' : ''
|
|
60
68
|
if (n.kind === 'agent') return agentNeedPanel(n, s)
|
|
61
69
|
const go = opts.goButton ? opts.goButton(s) : ''
|
|
62
|
-
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) + '">'
|
|
70
|
+
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) + '"' : '') + '>'
|
|
63
71
|
h += '<div class="need-head"><span class="need-dot" aria-hidden="true"></span>' +
|
|
64
72
|
(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>' : '')) +
|
|
65
73
|
(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>' : '') +
|
|
66
74
|
'<span class="need-kind">' + esc(KIND_LABEL[n.kind] || '') + '</span>' +
|
|
67
|
-
(n.waitSec != null ? '<span class="need-wait" title="since ' + esc(n.since || '') + '">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + '</div>'
|
|
75
|
+
(n.waitSec != null ? '<span class="need-wait" title="since ' + esc(n.since || '') + '">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
|
|
76
|
+
h += needContextHtml(n, opts) + prCardHtml(n.pr)
|
|
68
77
|
if (n.kind === 'permission' && n.ask) {
|
|
69
78
|
const d = n.ask.describe || {}
|
|
70
79
|
h += '<div class="need-what"><span class="verb">' + esc(d.verb || n.ask.tool) + '</span><code>' + esc(d.detail || '') + '</code></div>'
|
|
@@ -88,6 +97,7 @@ export function needPanel(n, opts = {}) {
|
|
|
88
97
|
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') + '>'
|
|
89
98
|
h += '</div>'
|
|
90
99
|
})
|
|
100
|
+
h += quickRepliesHtml(n, dis)
|
|
91
101
|
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>' +
|
|
92
102
|
'<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>'
|
|
93
103
|
} else if (n.kind === 'terminal') {
|
|
@@ -98,7 +108,7 @@ export function needPanel(n, opts = {}) {
|
|
|
98
108
|
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>'
|
|
99
109
|
if (n.canReply) {
|
|
100
110
|
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>' +
|
|
101
|
-
'<button type="button" class="btn primary" data-action="reply-send" data-submit-for="reply"' + dis + '>Send' + kbdHint(opts) + '</button></div
|
|
111
|
+
'<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>'
|
|
102
112
|
} else {
|
|
103
113
|
h += '<div class="need-note">' + esc(n.why ? 'No reply box: ' + n.why + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
|
|
104
114
|
}
|
|
@@ -165,16 +175,27 @@ export function agentNeedPanel(n, s = n.session || {}) {
|
|
|
165
175
|
if (a.reply == null && n.reply) a.reply = n.reply
|
|
166
176
|
if (a.replyHint == null && n.replyHint) a.replyHint = n.replyHint
|
|
167
177
|
const btn = agentReplyButton(a)
|
|
168
|
-
let h = '<article class="need k-agent" data-id="' + esc(s.id) + '" data-key="' + esc('agent:' + s.id) + '">' +
|
|
178
|
+
let h = '<article class="need k-agent" data-id="' + esc(s.id) + '" data-key="' + esc('agent:' + s.id) + '"' + (n.key ? ' data-need="' + esc(n.key) + '"' : '') + '>' +
|
|
169
179
|
'<div class="need-head">' + agentChipHtml(a) +
|
|
170
180
|
'<a class="need-title" href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled') + '</a>' +
|
|
171
|
-
'<span class="need-kind">' + esc(KIND_LABEL.agent) + '</span>' + (n.waitSec != null ? '<span class="need-wait">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + '</div>' +
|
|
181
|
+
'<span class="need-kind">' + esc(KIND_LABEL.agent) + '</span>' + (n.waitSec != null ? '<span class="need-wait">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>' +
|
|
172
182
|
'<ul class="agent-needs">' + (n.items || []).map((x) => '<li class="plain">' + esc(x.text) + '</li>').join('') + '</ul>' +
|
|
173
183
|
(btn ? '<div class="need-actions">' + btn + '</div>' : '') +
|
|
174
184
|
'<div class="need-note">' + esc(agentReplyNote(a)) + '</div>'
|
|
175
185
|
return h + '</article>'
|
|
176
186
|
}
|
|
177
187
|
|
|
188
|
+
// "Dismissed (n)" at the bottom of the Needs tab (0.6.5): what you hid that is
|
|
189
|
+
// still waiting, each with Restore. list: [{ key, title, line, kind, at }]
|
|
190
|
+
export function dismissedHtml(list = [], { open = false, agoFn = null } = {}) {
|
|
191
|
+
if (!list.length) return ''
|
|
192
|
+
return '<details class="dismissed"' + (open ? ' open' : '') + '><summary>Dismissed (' + list.length + ')</summary><ul>' +
|
|
193
|
+
list.map((d) => '<li><div class="dl-main"><span class="dl-title">' + esc(d.title || 'Untitled') + '</span><span class="dl-line">' + esc(String(d.line || '').slice(0, 200)) + '</span>' +
|
|
194
|
+
(agoFn && d.at ? '<span class="dl-at">dismissed ' + esc(agoFn(d.at)) + '</span>' : '') + '</div>' +
|
|
195
|
+
'<button type="button" class="btn sm" data-action="need-restore" data-need="' + esc(d.key) + '">Restore</button></li>').join('') +
|
|
196
|
+
'</ul>' + (list.length > 1 ? '<button type="button" class="linkish" data-action="need-restore-all">Restore all</button>' : '') + '</details>'
|
|
197
|
+
}
|
|
198
|
+
|
|
178
199
|
export function needsStrip(items, opts = {}) {
|
|
179
200
|
if (!items.length) return ''
|
|
180
201
|
const blocking = items.filter((i) => i.blocking).length
|
|
@@ -195,3 +216,214 @@ export function questionAnswer(questions = [], picks = {}, typed = {}) {
|
|
|
195
216
|
})
|
|
196
217
|
return { answers, allowOther: other }
|
|
197
218
|
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------- context (0.6.4)
|
|
221
|
+
// What Claude was doing when it asked: its last lines before the question,
|
|
222
|
+
// the branch and how long ago, "Show more" for the last ~20 turns (loaded
|
|
223
|
+
// from GET /v1/sessions/:id/turns, so it works through the relay too), the
|
|
224
|
+
// branch's open PR, and tap-to-send quick replies.
|
|
225
|
+
|
|
226
|
+
export function agoLabel(sec) {
|
|
227
|
+
if (sec == null || !isFinite(sec)) return ''
|
|
228
|
+
if (sec < 60) return 'just now'
|
|
229
|
+
if (sec < 3600) return Math.round(sec / 60) + 'm ago'
|
|
230
|
+
if (sec < 86400) return Math.round(sec / 3600) + 'h ago'
|
|
231
|
+
return Math.round(sec / 86400) + 'd ago'
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Claude's markdown, safely: everything is escaped first (mdInline), fenced
|
|
235
|
+
// code becomes a block that scrolls sideways, "- " / "1. " lines become lists.
|
|
236
|
+
export function mdBlocks(text) {
|
|
237
|
+
const out = []
|
|
238
|
+
let para = [], list = null, code = null
|
|
239
|
+
const flushPara = () => { if (para.length) { out.push('<p>' + para.map((l) => mdInline(l)).join('<br>') + '</p>'); para = [] } }
|
|
240
|
+
const flushList = () => { if (list) { out.push('<' + list.tag + '>' + list.items.map((t) => '<li>' + mdInline(t) + '</li>').join('') + '</' + list.tag + '>'); list = null } }
|
|
241
|
+
for (const raw of String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n')) {
|
|
242
|
+
if (code) {
|
|
243
|
+
if (/^\s*(```|~~~)\s*$/.test(raw)) { out.push('<pre class="tcode"><code>' + esc(code.join('\n')) + '</code></pre>'); code = null } else code.push(raw)
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
if (/^\s*(```|~~~)/.test(raw)) { flushPara(); flushList(); code = []; continue }
|
|
247
|
+
const li = raw.match(/^\s*(?:([-*+•])|(\d{1,3})[.)])\s+(.*)$/)
|
|
248
|
+
if (li) {
|
|
249
|
+
flushPara()
|
|
250
|
+
const tag = li[1] ? 'ul' : 'ol'
|
|
251
|
+
if (list && list.tag !== tag) flushList()
|
|
252
|
+
if (!list) list = { tag, items: [] }
|
|
253
|
+
list.items.push(li[3])
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
if (!raw.trim()) { flushPara(); flushList(); continue }
|
|
257
|
+
flushList()
|
|
258
|
+
const hd = raw.match(/^\s*#{1,6}\s+(.*)$/)
|
|
259
|
+
if (hd) { flushPara(); out.push('<p class="th">' + mdInline(hd[1]) + '</p>'); continue }
|
|
260
|
+
para.push(raw.trim())
|
|
261
|
+
}
|
|
262
|
+
if (code) out.push('<pre class="tcode"><code>' + esc(code.join('\n')) + '</code></pre>')
|
|
263
|
+
flushPara(); flushList()
|
|
264
|
+
return out.join('')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The turns "Show more" lists. turns: [{ role, text, chips: [{ label, error, n }] }]
|
|
268
|
+
// The question the card already shows in its own block (0.6.5): the reply
|
|
269
|
+
// kind's trailing question, or AskUserQuestion's first question.
|
|
270
|
+
export function askedText(n) {
|
|
271
|
+
if (!n) return ''
|
|
272
|
+
if (n.kind === 'reply') return n.question || ''
|
|
273
|
+
if (n.kind === 'question' && n.ask) return ((n.ask.questions || [])[0] || {}).question || ''
|
|
274
|
+
return ''
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Loose form for matching: no markup, one space, lower case, no leading "…"
|
|
278
|
+
// (a long question is kept by its end).
|
|
279
|
+
const flatQ = (s) => String(s == null ? '' : s).replace(/[*_`#>]+/g, '').replace(/\s+/g, ' ').trim().toLowerCase().replace(/^…\s*/, '')
|
|
280
|
+
|
|
281
|
+
// The text with the question cut off its end, or null when it doesn't end with it.
|
|
282
|
+
function cutQuestion(text, question) {
|
|
283
|
+
const q = flatQ(question)
|
|
284
|
+
if (q.length < 4) return null
|
|
285
|
+
const t = String(text || '')
|
|
286
|
+
const map = [] // each char of the flat copy -> its index in t
|
|
287
|
+
let flat = ''
|
|
288
|
+
for (let i = 0; i < t.length; i++) {
|
|
289
|
+
const c = t[i]
|
|
290
|
+
if (/[*_`#>]/.test(c)) continue
|
|
291
|
+
if (/\s/.test(c)) { if (!flat || flat.endsWith(' ')) continue; flat += ' '; map.push(i); continue }
|
|
292
|
+
flat += c.toLowerCase(); map.push(i)
|
|
293
|
+
}
|
|
294
|
+
flat = flat.trimEnd()
|
|
295
|
+
if (!flat.endsWith(q.slice(-Math.min(q.length, 40)))) return null
|
|
296
|
+
let at = flat.lastIndexOf(q)
|
|
297
|
+
if (at < 0) at = flat.lastIndexOf(q.slice(0, 40))
|
|
298
|
+
if (at < 0 || at + q.length < flat.length - 2 || /^\s*…/.test(String(question))) {
|
|
299
|
+
// Only the end matched (the question was clipped): drop the last paragraph.
|
|
300
|
+
const paras = t.trimEnd().split(/\n\s*\n/)
|
|
301
|
+
paras.pop()
|
|
302
|
+
return tidyEnd(paras.join('\n\n'))
|
|
303
|
+
}
|
|
304
|
+
return tidyEnd(t.slice(0, map[at]))
|
|
305
|
+
}
|
|
306
|
+
// Markup the cut left open ("**" before a bold question, a "> "), not a code fence.
|
|
307
|
+
const tidyEnd = (s) => s.replace(/(?:\s|[*_#>]|(?<!`)`(?!`))+$/, '')
|
|
308
|
+
|
|
309
|
+
// "Show more" turns without the question the card shows below them (0.6.5):
|
|
310
|
+
// when the last assistant turn ends with that question (or is the
|
|
311
|
+
// AskUserQuestion call), it's cut from the turn and the turn carries a small
|
|
312
|
+
// "↓ asked below" marker instead. A turn left empty is just the marker.
|
|
313
|
+
export function withoutAsked(turns = [], n = null) {
|
|
314
|
+
const list = turns.slice()
|
|
315
|
+
const last = list[list.length - 1]
|
|
316
|
+
if (!n || !last || last.role !== 'assistant') return list
|
|
317
|
+
const t = { ...last, chips: (last.chips || []).slice() }
|
|
318
|
+
let asked = false
|
|
319
|
+
if (n.kind === 'question') {
|
|
320
|
+
const c = t.chips[t.chips.length - 1]
|
|
321
|
+
if (c && c.label === 'Asked you') {
|
|
322
|
+
if (c.n > 1) t.chips[t.chips.length - 1] = { ...c, n: c.n - 1 }
|
|
323
|
+
else t.chips.pop()
|
|
324
|
+
asked = true
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const q = askedText(n)
|
|
328
|
+
if (q && t.text && !t.chips.length) {
|
|
329
|
+
const cut = cutQuestion(t.text, q)
|
|
330
|
+
if (cut != null) { t.text = cut; asked = true }
|
|
331
|
+
}
|
|
332
|
+
if (!asked) return list
|
|
333
|
+
t.asked = true
|
|
334
|
+
list[list.length - 1] = t
|
|
335
|
+
return list
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// opts.need: the Needs item these turns belong to (its question isn't repeated).
|
|
339
|
+
export function turnsHtml(turns = [], opts = {}) {
|
|
340
|
+
if (!turns.length) return '<div class="need-note">Nothing in this session’s transcript yet.</div>'
|
|
341
|
+
return '<ol class="turns">' + withoutAsked(turns, opts.need).map((t) => {
|
|
342
|
+
const body = (t.text ? '<div class="turn-text">' + mdBlocks(t.text) + '</div>' : '') +
|
|
343
|
+
((t.chips || []).length ? '<div class="tchips">' + t.chips.map((c) => '<span class="tchip' + (c.error ? ' err' : '') + '">' + esc(c.label) + (c.n > 1 ? ' <b>×' + c.n + '</b>' : '') + '</span>').join('') + '</div>' : '')
|
|
344
|
+
const mark = t.asked ? '<div class="turn-asked">↓ asked below</div>' : ''
|
|
345
|
+
if (t.asked && !body) return '<li class="turn claude asked-only">' + mark + '</li>'
|
|
346
|
+
return '<li class="turn ' + (t.role === 'user' ? 'you' : 'claude') + '"><div class="turn-who">' + (t.role === 'user' ? 'You' : 'Claude') + '</div>' + body + mark + '</li>'
|
|
347
|
+
}).join('') + '</ol>'
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// opts.expanded: { [sessionId]: true }, opts.turns: { [sessionId]: { turns } | { error } }
|
|
351
|
+
export function needContextHtml(n, opts = {}) {
|
|
352
|
+
const c = n.context
|
|
353
|
+
const s = n.session || {}
|
|
354
|
+
if (!c || n.kind === 'agent') return ''
|
|
355
|
+
const lines = (c.lines || []).filter(Boolean)
|
|
356
|
+
const where = [opts.compact && s.repoLabel ? s.repoLabel : '', c.branch ? '⎇ ' + c.branch : ''].filter(Boolean)
|
|
357
|
+
const when = n.waitSec != null ? (n.kind === 'reply' ? 'asked ' : '') + agoLabel(n.waitSec) : ''
|
|
358
|
+
const canMore = !!c.transcript || !!n.peer
|
|
359
|
+
if (!lines.length && !where.length && !canMore) return ''
|
|
360
|
+
const open = !!(opts.expanded && opts.expanded[s.id])
|
|
361
|
+
const loaded = opts.turns && opts.turns[s.id]
|
|
362
|
+
let h = '<div class="need-ctx' + (open && loaded && !loaded.error ? ' open' : '') + '">'
|
|
363
|
+
if (lines.length) h += '<div class="ctx-lines">' + lines.map((l) => '<p>' + mdInline(String(l).replace(/^\s*(?:[-*+]|\d{1,3}[.)])\s+/, (m) => /\d/.test(m) ? m.trim() + ' ' : '• ')) + '</p>').join('') + '</div>'
|
|
364
|
+
h += '<div class="ctx-meta">' + (where.length ? '<span class="mono">' + esc(where.join(' · ')) + '</span>' : '') + (where.length && when ? '<span aria-hidden="true"> · </span>' : '') + (when ? '<span>' + esc(when) + '</span>' : '') +
|
|
365
|
+
(canMore ? '<button type="button" class="linkbtn need-more" data-action="need-more" data-id="' + esc(s.id) + '" aria-expanded="' + open + '">' + (open ? 'Show less' : 'Show more') + '</button>' : '') + '</div>'
|
|
366
|
+
if (open) h += '<div class="need-turns">' + (!loaded ? '<div class="need-note">Loading the last turns…</div>' : loaded.error ? '<div class="need-note warn">' + esc(loaded.error) + '</div>' : turnsHtml(loaded.turns, { need: n })) + '</div>'
|
|
367
|
+
return h + '</div>'
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// The branch's open pull request (from gh on the machine running the session).
|
|
371
|
+
export function prCardHtml(pr) {
|
|
372
|
+
if (!pr || !pr.number) return ''
|
|
373
|
+
const url = httpsOnly(pr.url)
|
|
374
|
+
const ck = pr.checks || {}
|
|
375
|
+
const cls = ck.failed ? 'fail' : ck.pending ? 'pending' : ck.total ? 'pass' : 'none'
|
|
376
|
+
const icon = { fail: '✕', pending: '◌', pass: '✓', none: '' }[cls]
|
|
377
|
+
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>' +
|
|
378
|
+
(ck.summary ? '<span class="pr-checks ' + cls + '">' + (icon ? '<span aria-hidden="true">' + icon + '</span> ' : '') + esc(ck.summary) + '</span>' : '') + '</span>' +
|
|
379
|
+
'<span class="pr-title">' + esc(pr.title || '') + '</span>'
|
|
380
|
+
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>'
|
|
381
|
+
: '<div class="need-pr">' + inner + '</div>'
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export const GENERIC_REPLIES = ['Yes, go ahead', 'No', 'Show me the diff first']
|
|
385
|
+
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1)
|
|
386
|
+
|
|
387
|
+
// "Should I also add Google sign-in, or keep it email-only?" -> the two choices.
|
|
388
|
+
export function orChoices(question) {
|
|
389
|
+
const q = String(question || '').replace(/\s+/g, ' ').trim()
|
|
390
|
+
const last = (q.match(/[^.!?]*\?\s*$/) || [''])[0].trim()
|
|
391
|
+
const m = last.match(/^(?:so\s+|and\s+)?(?:should|shall|do you want me to|want me to|would you like me to|would you rather I|can|could)\s+(?:i|we)?\s*(?:also\s+|just\s+|first\s+)?(.+?),?\s+or\s+(?:should\s+(?:i|we)\s+|do you want me to\s+|would you rather\s+)?(.+?)\s*\?$/i)
|
|
392
|
+
if (!m) return []
|
|
393
|
+
const a = m[1].trim(), b = m[2].trim().replace(/^(?:i|we)\s+/i, '')
|
|
394
|
+
if (!a || !b || /^(not|no|something else|neither|anything else)\b/i.test(b) || a.length > 60 || b.length > 60) return []
|
|
395
|
+
return [cap(a), cap(b)]
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 3–4 tap-to-send replies: a question's own options when it has them
|
|
399
|
+
// (AskUserQuestion choices, numbered options, "X, or Y?"), else generic ones.
|
|
400
|
+
export function quickReplies(n) {
|
|
401
|
+
if (!n) return []
|
|
402
|
+
if (n.kind === 'question' && n.ask) {
|
|
403
|
+
const qs = n.ask.questions || []
|
|
404
|
+
if (qs.length !== 1) return []
|
|
405
|
+
const q = qs[0]
|
|
406
|
+
if (q.options && q.options.length) return q.multiSelect ? [] : q.options.map((o) => o.label).slice(0, 4)
|
|
407
|
+
return orChoices(q.question).length ? [...orChoices(q.question), GENERIC_REPLIES[2]] : GENERIC_REPLIES
|
|
408
|
+
}
|
|
409
|
+
if (n.kind !== 'reply') return []
|
|
410
|
+
const listed = (n.context && n.context.options) || []
|
|
411
|
+
if (listed.length >= 2) return listed.slice(0, 4)
|
|
412
|
+
const alt = orChoices(n.question)
|
|
413
|
+
if (alt.length) return [...alt, GENERIC_REPLIES[2]]
|
|
414
|
+
if (/\(\s*y\s*\/\s*n\s*\)|\byes or no\b/i.test(String(n.question || ''))) return ['Yes', 'No']
|
|
415
|
+
return GENERIC_REPLIES
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Under the answer box. A question whose options are already one-tap buttons
|
|
419
|
+
// gets none (they'd be the same buttons twice); nothing to type into, none.
|
|
420
|
+
export function quickRepliesHtml(n, dis = '') {
|
|
421
|
+
if (n.kind === 'reply' && !n.canReply) return ''
|
|
422
|
+
if (n.kind === 'question' && n.ask) {
|
|
423
|
+
const qs = n.ask.questions || []
|
|
424
|
+
if (qs.length !== 1 || (qs[0].options || []).length) return ''
|
|
425
|
+
}
|
|
426
|
+
const list = quickReplies(n)
|
|
427
|
+
if (!list.length) return ''
|
|
428
|
+
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>'
|
|
429
|
+
}
|
package/ui/relay-client.js
CHANGED
|
@@ -152,9 +152,13 @@ function needsKey() {
|
|
|
152
152
|
screen('Pair this browser again', '<p>This browser has a pass for ' + esc(RELAY_USER) + '.leftoff.dev but not the key that unlocks your board (it lives only in this browser, and it’s gone: private browsing, cleared website data, or another browser).</p>' +
|
|
153
153
|
'<p>On a Mac: Settings → Phone & remote → <b>Pair a phone</b>, then scan the QR code.</p><p><a class="btn primary" href="/pair.html">I have a code</a></p>')
|
|
154
154
|
}
|
|
155
|
+
let notPairedShown = false
|
|
155
156
|
function notPaired(revoked) {
|
|
157
|
+
// once shown, a later 401 (the event stream, a poll) doesn't redraw it
|
|
158
|
+
if (notPairedShown) return
|
|
159
|
+
notPairedShown = true
|
|
156
160
|
forgetDeviceKey()
|
|
157
|
-
screen('This browser isn’t paired anymore', '<p>' + (revoked ? 'It was revoked on one of your
|
|
161
|
+
screen('This browser isn’t paired anymore', '<p>' + (revoked === 'self' ? 'You revoked it.' : revoked ? 'It was revoked on one of your devices.' : 'It was revoked, or its pass expired.') + ' Pair it again from a Mac: Settings → Phone & remote → <b>Pair a phone</b>.</p><p><a class="btn primary" href="/pair.html">I have a code</a></p>')
|
|
158
162
|
}
|
|
159
163
|
let asleepShown = false
|
|
160
164
|
function asleep() {
|
|
@@ -175,6 +179,8 @@ export const leftoffRelay = {
|
|
|
175
179
|
status: () => refreshStatus(),
|
|
176
180
|
// Switch Macs: the board reloads from the other one.
|
|
177
181
|
pick(mac) { remember(mac); cookieMac(mac); location.reload() },
|
|
182
|
+
// This browser revoked itself (0.6.6).
|
|
183
|
+
unpaired() { notPaired('self') },
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
if (RELAY_USER) {
|
package/ui/repos.js
CHANGED
|
@@ -95,6 +95,8 @@ export const STATUS_FILTERS = {
|
|
|
95
95
|
waiting: ['waiting'],
|
|
96
96
|
dormant: ['dormant', 'stale'],
|
|
97
97
|
ended: ['ended'],
|
|
98
|
+
// 0.6.7: every ended session, not just today's (the Board's Closed view)
|
|
99
|
+
closed: ['ended'],
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export const STORE_KEY = 'cp.repos'
|
|
@@ -151,8 +153,15 @@ export function toggleRepo(selected, chip, multi = false) {
|
|
|
151
153
|
|
|
152
154
|
// Status AND repo. `repos` are tokens (labels); an empty list means all repos.
|
|
153
155
|
export function applyFilter(sessions, { repos = [], status = null } = {}) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
return sessions.filter((s) => (!status || inStatus(s, status)) && (!repos.length || repos.some((r) => repoMatches(s, r))))
|
|
157
|
+
}
|
|
158
|
+
// A session in a status filter. "Needs you" also counts anything with a Needs
|
|
159
|
+
// item (an agent feed asking you something, a turn that ended on a question),
|
|
160
|
+
// so the tile agrees with the strip and the tab's count (0.6.5).
|
|
161
|
+
export function inStatus(s, status) {
|
|
162
|
+
const statuses = STATUS_FILTERS[status]
|
|
163
|
+
if (!statuses) return true
|
|
164
|
+
return statuses.includes(s.status) || (status === 'needs' && !!s.needsYou && !s.archived)
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
// Which chips show in the row and which go behind "More ▾": the top `max` by
|
package/ui/settings-nav.js
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
// box also matches, and the old names a link may still use. Pure data, so the
|
|
3
3
|
// unit tests read it too (test/unit/settings-nav.test.mjs).
|
|
4
4
|
export const SETTINGS = [
|
|
5
|
-
{ key: 'general', label: 'General', icon: 'sliders-horizontal', sub: 'Checkpoints, notifications and theme.', words: 'dormant gap checkpoint notify notification needs you macos theme appearance light dark system', save: true },
|
|
5
|
+
{ key: 'general', label: 'General', icon: 'sliders-horizontal', sub: 'Checkpoints, notifications and theme.', words: 'dormant gap checkpoint notify notification needs you macos theme appearance light dark system pr cards pull request github gh', save: true },
|
|
6
6
|
{ key: 'digest', label: 'Digest', icon: 'newspaper', sub: 'When the daily digest is written.', words: 'time days weekdays every day quiet skip empty time zone schedule morning automatic', save: true },
|
|
7
7
|
{ key: 'spaces', label: 'Spaces', icon: 'layers', sub: 'Work, Personal or your own names, per Mac and per repo.', words: 'work personal repo override mac default standup agent feed' },
|
|
8
8
|
{ key: 'sync', label: 'Sync', icon: 'folder-sync', sub: 'One folder, every Mac.', words: 'syncthing pair pairing join code icloud dropbox folder move backup other macs install accept' },
|
|
9
9
|
{ key: 'agents', label: 'Agents', icon: 'bot', sub: 'Work from AI assistants outside Claude Code.', words: 'tab feed agent key url host hosting add refresh remove' },
|
|
10
10
|
{ key: 'models', label: 'Models & summaries', icon: 'sparkles', sub: 'Who writes your checkpoints, and how long they are.', words: 'summarizer provider model ollama anthropic openai claude haiku sonnet opus api key server url length short long informative', save: true },
|
|
11
11
|
{ key: 'phone', label: 'Phone & remote', icon: 'smartphone', sub: 'Your board on your iPhone, from anywhere or over Tailscale.', words: 'use from anywhere relay leftoff.dev subdomain pair pairing device devices revoke tailscale remote access qr code sign in signed iphone home screen url app' },
|
|
12
|
-
{ key: 'security', label: 'Privacy & security', icon: 'shield', sub: '
|
|
12
|
+
{ key: 'security', label: 'Privacy & security', icon: 'shield', sub: 'Trust my paired devices, and machine keys.', words: 'answer answering permission prompts trust trusted paired devices phone other machines go keys fingerprint privacy data protected encryption advanced', save: true },
|
|
13
13
|
{ key: 'updates', label: 'Updates & about', icon: 'info', sub: 'Version, updates and this Mac.', words: 'update upgrade version check about machine folder what’s new whats new changelog', save: true },
|
|
14
14
|
]
|
|
15
|
-
export const SETTINGS_ALIAS = { anywhere: 'phone', relay: 'phone', remote: 'phone', tailscale: 'phone', iphone: 'phone', machines: 'security', answering: 'security', privacy: 'security', summaries: 'models', summarizer: 'models', model: 'models', ai: 'models', syncthing: 'sync', pairing: 'sync', pair: 'sync', about: 'updates', update: 'updates', version: 'updates', checkpoints: 'general', appearance: 'general', theme: 'general', notify: 'general' }
|
|
15
|
+
export const SETTINGS_ALIAS = { anywhere: 'phone', relay: 'phone', remote: 'phone', tailscale: 'phone', iphone: 'phone', machines: 'security', answering: 'security', trust: 'security', privacy: 'security', summaries: 'models', summarizer: 'models', model: 'models', ai: 'models', syncthing: 'sync', pairing: 'sync', pair: 'sync', about: 'updates', update: 'updates', version: 'updates', checkpoints: 'general', appearance: 'general', theme: 'general', notify: 'general' }
|
|
16
16
|
// The section a route asks for: #/settings/<key>, or the old ?<key> form
|
|
17
17
|
// (#/settings?spaces, ?sync, ?remote…); null means the list.
|
|
18
18
|
export function settingsKeyFor(arg, paramKeys = []) {
|