@leftoff/cli 0.9.18-beta.9 → 0.9.18
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 +25 -2
- package/bin/leftoff-statusline.js +7 -7
- package/bin/leftoff.js +792 -677
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +259 -17
- package/mod/hooks/register.js +18 -4
- package/package.json +1 -1
- package/ui/app.css +204 -5
- package/ui/app.js +621 -189
- package/ui/chip-fade.js +1 -1
- package/ui/cp-fold.js +42 -0
- package/ui/decisions.js +81 -21
- package/ui/digest-versions.js +57 -0
- package/ui/fs-side.js +29 -0
- package/ui/html-preview.html +12 -0
- package/ui/html-preview.js +136 -0
- package/ui/icons.js +1 -0
- package/ui/live-status.js +44 -3
- package/ui/live-view.js +36 -5
- package/ui/live-wake.js +47 -0
- package/ui/mcp-setup.js +26 -0
- package/ui/prs.js +28 -10
- package/ui/pull.js +30 -3
- package/ui/relay-client.js +2 -0
- package/ui/render-digest.js +98 -0
- package/ui/render-export.js +128 -0
- package/ui/semdiff.js +58 -27
- package/ui/session-title.js +88 -0
- package/ui/settings-nav.js +1 -1
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/subagents-view.js +41 -0
- package/ui/summary-examples.js +1 -1
- package/ui/tabs.js +10 -3
- package/ui/viewport-fit.js +29 -3
- package/ui/walk-spots.js +35 -1
package/ui/semdiff.js
CHANGED
|
@@ -371,11 +371,25 @@ function fileMapHtml(data, { esc, icon, cur }) {
|
|
|
371
371
|
return h + '<div class="wt-fm-k"><span class="kbd">j</span><span class="kbd">k</span> next step</div></nav>'
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
// 2026-11-06 every PR the session has is its own range ('pr:<owner/repo>#<n>'): up to PR_CHIPS of them are
|
|
375
|
+
// chips; more fold into one "PRs" chip that opens a menu (the one picked shows its number)
|
|
376
|
+
export const PR_CHIPS = 2
|
|
377
|
+
export const isPrKey = (k) => k === 'pr' || /^pr:/.test(String(k || ''))
|
|
378
|
+
const PR_WORD = { merged: 'merged', closed: 'closed', draft: 'draft' }
|
|
379
|
+
export function prScopeTip(x) { return [x.title, x.repo, x.state ? (x.state === 'open' ? 'Open' : x.state[0].toUpperCase() + x.state.slice(1)) : ''].filter(Boolean).join(' · ') }
|
|
374
380
|
function scopesHtml(data, scope, { esc, icon, sid }) {
|
|
375
381
|
const sc = (data && data.scopes) || [{ key: 'checkpoint', label: SCOPE_WORDS.checkpoint, available: false }, { key: 'session', label: SCOPE_WORDS.session, available: true }]
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
(x.key
|
|
382
|
+
const prs = sc.filter((x) => isPrKey(x.key)), rest = sc.filter((x) => !isPrKey(x.key))
|
|
383
|
+
const chip = (x) => '<button type="button" data-action="wt-scope" data-id="' + esc(sid) + '" data-scope="' + esc(x.key) + '" aria-pressed="' + (x.key === scope ? 'true' : 'false') + '"' + (x.available === false ? ' disabled data-tip="No checkpoint yet"' : isPrKey(x.key) && prScopeTip(x) ? ' data-tip="' + esc(prScopeTip(x)) + '"' : '') + '>' +
|
|
384
|
+
(isPrKey(x.key) ? icon('git-pull-request') : '') + '<span>' + esc(x.label) + (PR_WORD[x.state] ? ' · ' + PR_WORD[x.state] : '') + '</span></button>'
|
|
385
|
+
let h = rest.map(chip).join('')
|
|
386
|
+
if (prs.length <= PR_CHIPS) h += prs.map(chip).join('')
|
|
387
|
+
else {
|
|
388
|
+
const cur = prs.find((x) => x.key === scope)
|
|
389
|
+
h += '<button type="button" data-action="wt-prs" data-id="' + esc(sid) + '" aria-pressed="' + (cur ? 'true' : 'false') + '" aria-haspopup="menu" aria-expanded="false" aria-label="' + esc(cur ? cur.label + ', pick another of ' + prs.length + ' PRs' : 'Pick one of ' + prs.length + ' PRs') + '"' + (cur && prScopeTip(cur) ? ' data-tip="' + esc(prScopeTip(cur)) + '"' : '') + '>' +
|
|
390
|
+
icon('git-pull-request') + '<span>' + esc(cur ? cur.label + (PR_WORD[cur.state] ? ' · ' + PR_WORD[cur.state] : '') : 'PRs · ' + prs.length) + '</span>' + icon('chevron-down', 'ic wt-seg-chev') + '</button>'
|
|
391
|
+
}
|
|
392
|
+
return '<div class="wt-seg" role="group" aria-label="What to walk through">' + h + '</div>'
|
|
379
393
|
}
|
|
380
394
|
|
|
381
395
|
// 2026-10-30 (asked: "the left side file list should be somewhat collapsible"): folded to a
|
|
@@ -395,7 +409,7 @@ function railHtml(data, big, { esc, icon }) {
|
|
|
395
409
|
}
|
|
396
410
|
|
|
397
411
|
// 2026-10-30 Next spot: in the toolbar (not the step's footer, where Mark reviewed & next is)
|
|
398
|
-
export const SPOT_TIP = '
|
|
412
|
+
export const SPOT_TIP = 'Next spot: a line worth a real look, from where you are. The walkthrough flagged it, or it is a risky kind of change (removed logic, auth, config, error handling) · n, Shift+N back'
|
|
399
413
|
function spotBtnHtml(n, cur, { icon }) {
|
|
400
414
|
if (!n) return ''
|
|
401
415
|
return '<button type="button" class="btn wt-spot" data-action="wt-spot" data-tip="' + SPOT_TIP + '" aria-label="Next spot to look at, ' + spotCountWords(n, cur) + '">' + icon('crosshair') + '<span class="wt-spot-l">Next spot</span><span class="wt-spot-n" id="wtSpotN">' + spotCountWords(n, cur) + '</span></button>'
|
|
@@ -421,12 +435,12 @@ function staleBarHtml(st, { esc, icon, sid, loading }) {
|
|
|
421
435
|
'<button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '"' + (loading ? ' disabled' : '') + '>' + icon('refresh-cw') + (loading ? 'Updating…' : 'Update') + '</button></p>'
|
|
422
436
|
}
|
|
423
437
|
|
|
424
|
-
// Download .html / Copy as Markdown: always in the header (icons on a desktop, words on a phone);
|
|
438
|
+
// Save as HTML (was Download .html) / Copy as Markdown: always in the header (icons on a desktop, words on a phone);
|
|
425
439
|
// with nothing to export yet they stay, dimmed, and their tip says why
|
|
426
440
|
function exportsHtml(ok, ui, { icon }) {
|
|
427
441
|
const why = ui.loading ? 'Still reading the change' : ui.error ? 'Nothing to export: the walkthrough didn’t load' : 'Nothing to export yet'
|
|
428
442
|
const b = (action, ic, label, tip) => '<button type="button" class="btn wt-exp-b" data-action="' + action + '" aria-label="' + label + '" data-tip="' + (ok ? tip : why) + '"' + (ok ? '' : ' aria-disabled="true"') + '>' + icon(ic) + '<span class="wt-exp-l">' + label + '</span></button>'
|
|
429
|
-
return '<span class="wt-exp">' + b('wt-export-html', 'download', '
|
|
443
|
+
return '<span class="wt-exp">' + b('wt-export-html', 'download', 'Save as HTML', 'Save as HTML · one file, opens offline anywhere') + b('wt-export-md', 'copy', 'Copy as Markdown', 'Copy as Markdown · for a PR description') + '</span>'
|
|
430
444
|
}
|
|
431
445
|
|
|
432
446
|
// The whole walkthrough. ui: { scope, view: 'story'|'files', filter, open: Set(step ids with code shown), groups: Set(kinds open), cur, loading, error, ask }
|
|
@@ -449,7 +463,9 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
|
|
|
449
463
|
'<button type="button" class="wt-x" data-action="wt-close" data-id="' + esc(sid) + '" aria-label="Close the walkthrough">' + icon('x') + '</button></div>'
|
|
450
464
|
// 2026-10-30 the reading tools, a slim second row of the toolbar (the first stays one row): Next spot, Unified / Split
|
|
451
465
|
// 2026-11-03 This session · Whole folder, at the top of the story and the Files list alike
|
|
452
|
-
|
|
466
|
+
// 2026-11-06 Next spot lives in the bar under the reading (Joel: "should probably always be visible as you scroll")
|
|
467
|
+
const nSpots = ok && view === 'story' ? spotsFor(data, readingOrder(data.steps, data)).length : 0
|
|
468
|
+
const tools = fileScopeSegHtml(data && data.fileScope, { esc, icon, action: 'wt-files', busy: !!ui.loading }) +
|
|
453
469
|
modeSegHtml(ok || (data && Array.isArray(data.files) && data.files.length), ui.split)
|
|
454
470
|
if (tools) h += '<div class="wt-r2" role="toolbar" aria-label="Which changes and reading tools">' + tools + '</div>'
|
|
455
471
|
// which model wrote it (Settings → Models → Walk me through it picks it)
|
|
@@ -464,7 +480,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
|
|
|
464
480
|
if (ui.loading) h += '<p class="wt-sum wt-loading" role="status"><span class="wt-spin" aria-hidden="true"></span><span>Reading the change' + (data && data.status === 'ok' ? '' : '…') + '</span><button type="button" class="linkish wt-stop" data-action="wt-stop" data-id="' + esc(sid) + '">Stop</button></p>'
|
|
465
481
|
if (ui.error) h += '<p class="wt-note" role="alert">' + icon('circle-alert') + '<span>' + esc(ui.error) + '</span></p>'
|
|
466
482
|
if (data && !ui.loading && data.status !== 'ok' && data.message) {
|
|
467
|
-
const msg = data.status === 'empty' && scope === 'session' ? 'No changes in this session yet.' : data.status === 'empty' && scope
|
|
483
|
+
const msg = data.status === 'empty' && scope === 'session' ? 'No changes in this session yet.' : data.status === 'empty' && isPrKey(scope) ? 'No changes in this PR yet.' : data.message
|
|
468
484
|
h += '<p class="wt-note">' + icon('info') + '<span>' + esc(msg) + (data.status === 'no_model' ? ' <a href="#/settings/summaries">Settings</a>' : '') + '</span></p>'
|
|
469
485
|
h += unreadableHtml(data, { esc, icon }) // 2026-10-29 a repo that couldn't be read says so
|
|
470
486
|
// a fallback says why (the first reason; the model's whole reply: leftoff walkthrough debug)
|
|
@@ -473,7 +489,7 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
|
|
|
473
489
|
if (data.status === 'fallback' && data.small) h += '<p class="faint wt-small">' + icon('info') + '<span>Small local models struggle here. Try Claude or qwen2.5-coder 7B in <a href="#/settings/models">Settings</a>.</span></p>'
|
|
474
490
|
}
|
|
475
491
|
// 2026-11-03 switched to the other scope with nothing written for it: never a model run on its own; Write it is one tap
|
|
476
|
-
if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
|
|
492
|
+
if (data && !ui.loading && data.status === 'none' && view === 'story') h += '<p class="wt-stale" role="status"><i class="wt-chg-g" aria-hidden="true"></i><span>' + (isPrKey(ui.scope) ? 'No walkthrough of this PR yet.' : data.fileScope && data.fileScope.key === 'folder' ? 'No walkthrough of the whole folder yet.' : 'No walkthrough of this session’s changes yet.') + ' The files are under Files.</span><button type="button" class="linkish wt-upd" data-action="wt-update" data-id="' + esc(sid) + '">' + icon('refresh-cw') + 'Write it</button></p>'
|
|
477
493
|
const deck = ok && view === 'story' && ui.deck !== false && (data.steps || []).length > 0 // 0.9.18 the slideshow
|
|
478
494
|
if (ok && view === 'story' && !deck) {
|
|
479
495
|
const steps = data.steps || []
|
|
@@ -531,12 +547,12 @@ export function walkthroughHtml({ s, data, ui, reviewed = new Set(), esc, icon,
|
|
|
531
547
|
}
|
|
532
548
|
const fm = big ? fileMapHtml(data, { esc, icon, cur: ui.cur }) : fl
|
|
533
549
|
const dock = '<div class="wt-refs" id="wtRefs" role="list" aria-label="Lines you picked" hidden></div><button type="button" class="wt-askl" id="wtAskL" data-action="wt-ask-lines" hidden>' + icon('message-square') + '<span>Ask about these lines</span></button>' + '<form class="wt-ask" data-action-form="wt-ask" data-id="' + esc(sid) + '"><label class="lo-vh" for="wtAsk">Ask about this change</label><input id="wtAsk" class="wt-ask-in" type="text" autocomplete="off" placeholder="Ask about this change…" value="' + esc(ui.ask || '') + '"><button type="submit" class="wt-ask-b" aria-label="Put the question in the chat" data-tip="Into the chat box">' + icon('arrow-up') + '</button></form>'
|
|
534
|
-
const pnav = ok && view === 'story' && (data.steps || []).length ? '<div class="wt-pnav"><button type="button" class="btn" data-action="wt-step" data-dir="-1">' + icon('chevron-left') + 'Back</button><span class="faint" id="wtPnavN">' + stepPos(data.steps, ui) + '</span
|
|
550
|
+
const pnav = ok && view === 'story' && (data.steps || []).length ? '<div class="wt-pnav"><button type="button" class="btn" data-action="wt-step" data-dir="-1">' + icon('chevron-left') + 'Back</button><span class="faint" id="wtPnavN">' + stepPos(data.steps, ui) + '</span>' + spotBtnHtml(nSpots, ui.spot, { icon }) + '<button type="button" class="btn" data-action="wt-step" data-dir="1">Next' + icon('chevron-right') + '</button></div>' : ''
|
|
535
551
|
const dk = deck ? deckSlides(data, ui) : null
|
|
536
552
|
return '<div class="wt' + (big ? ' wt-big' : fl ? ' wt-fls' : '') + (deck ? ' wt-deckmode' : '') + '" id="wt" data-sid="' + esc(sid) + '" role="region" aria-labelledby="wtTitle"><div class="wt-grab" aria-hidden="true"></div>' +
|
|
537
553
|
(deck ? '<div class="wt-dprog" aria-hidden="true"><i id="wtDprog" data-p="' + Math.round(((dk.cur + 1) / dk.slides.length) * 1000) / 10 + '"></i></div>' : '') +
|
|
538
554
|
// 0.9.18 (Joel): with a file list on the left, the list and the reading each scroll on their own
|
|
539
|
-
(fm ? '<div class="wt-2p' + (ui.rail ? ' rail' : '') + '" id="wt2p">' + railHtml(data, big, { esc, icon }) + fm + '<div class="wt-rz" role="separator" aria-orientation="vertical" aria-label="Resize the file list" tabindex="0" data-tip="Drag to resize · [ hides the list"></div>' : '') + '<div class="wt-scroll" id="wtScroll"><div class="wt-hw"><div class="wt-in">' + h + body + '</div></div></div>' + (fm ? '</div>' : '') + (deck ? deckNavHtml(dk, { esc, icon, reviewed }) : pnav) + dock + '</div>'
|
|
555
|
+
(fm ? '<div class="wt-2p' + (ui.rail ? ' rail' : '') + '" id="wt2p">' + railHtml(data, big, { esc, icon }) + fm + '<div class="wt-rz" role="separator" aria-orientation="vertical" aria-label="Resize the file list" tabindex="0" data-tip="Drag to resize · [ hides the list"></div>' : '') + '<div class="wt-scroll" id="wtScroll"><div class="wt-hw"><div class="wt-in">' + h + body + '</div></div></div>' + (fm ? '</div>' : '') + (deck ? deckNavHtml(dk, { esc, icon, reviewed, spots: nSpots, spot: ui.spot }) : pnav) + dock + '</div>'
|
|
540
556
|
}
|
|
541
557
|
|
|
542
558
|
// 0.9.18 the walkthrough as slides (Joel: "almost a slide show, each slide was
|
|
@@ -562,11 +578,14 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
|
|
|
562
578
|
let h = '<div class="wt-deck" id="wtDeck" data-cur="' + cur + '" data-n="' + total + '" tabindex="-1" aria-roledescription="slideshow" aria-label="Walkthrough slides">'
|
|
563
579
|
// the overview
|
|
564
580
|
const st = data.stats || {}
|
|
565
|
-
const scope = SCOPE_WORDS[ui.scope] || ''
|
|
581
|
+
const scope = SCOPE_WORDS[ui.scope] || (((data.scopes || []).find((x) => x.key === ui.scope) || {}).label) || ''
|
|
566
582
|
const pm = Array.isArray(data.files) && data.files.length ? data.files.reduce((a, f) => ({ add: a.add + (f.add || 0), del: a.del + (f.del || 0) }), { add: 0, del: 0 }) : null
|
|
567
583
|
let o = '<p class="wt-sum big"><b>' + esc(data.headline) + '</b></p>' + (data.summary ? '<p class="wt-sum md-in">' + walkInlineLinks(data.summary) + '</p>' : '')
|
|
568
584
|
o += '<div class="wt-meta">' + (scope ? '<span>' + icon('git-compare') + esc(scope) + '</span>' : '') + '<span>' + plural(st.files || 0, 'file') + (pm ? ' <b class="wt-pl">+' + n0(pm.add) + '</b> <b class="wt-mi">−' + n0(pm.del) + '</b>' : '') + '</span><span>' + plural(steps.length, 'step') + '</span><span id="wtProg">' + steps.filter((x) => reviewed.has(x.id)).length + ' of ' + steps.length + ' reviewed</span></div>'
|
|
569
585
|
o += barHtml(st, { esc })
|
|
586
|
+
// 2026-11-06 the finish: "& finish" lands here, and this says it's done (app.js wtMarks keeps it current)
|
|
587
|
+
const allRev = steps.length > 0 && steps.every((x) => reviewed.has(x.id))
|
|
588
|
+
o += '<p class="wt-alldone" id="wtAllDone" role="status"' + (allRev ? '' : ' hidden') + '>' + icon('circle-check') + '<span>All ' + plural(steps.length, 'step') + ' reviewed</span></p>'
|
|
570
589
|
if (data.passes > 1) o += '<p class="wt-note">' + icon('info') + '<span>Too big for one pass, so it was read in two: each folder on its own (' + plural(data.folders, 'folder') + '), then the changes ranked across all of them. Lockfiles and generated files were never sent.</span></p>'
|
|
571
590
|
const cnt = (k) => steps.reduce((a, x) => a + x.callouts.filter((c) => c.kind === k).length, 0)
|
|
572
591
|
const chip = (k, label, ic, n) => '<button type="button" class="wt-fc" data-action="wt-filter" data-filter="' + k + '" aria-pressed="' + ((ui.filter || 'all') === k) + '">' + (ic ? icon(ic) : '') + '<span>' + label + '</span>' + (n != null ? '<b>' + n + '</b>' : '') + '</button>'
|
|
@@ -596,29 +615,40 @@ function deckHtml(data, ui, { esc, icon, reviewed, sid }) {
|
|
|
596
615
|
})
|
|
597
616
|
return h + '</div>'
|
|
598
617
|
}
|
|
599
|
-
// the bar under the slides: ← Prev · the current title · numbered dots · 3 / 12 · Next →
|
|
618
|
+
// the bar under the slides: ← Prev · the current title · numbered dots · 3 / 12 · Next spot · Next →
|
|
600
619
|
// 0.9.19 (Joel: "the walk through should have 2 next buttons. One that marks as
|
|
601
620
|
// viewed"): on a step's slide, a primary "Mark reviewed & next" beside the plain
|
|
602
|
-
// Next
|
|
603
|
-
//
|
|
621
|
+
// Next. Not shown on the overview or Safe to skip.
|
|
622
|
+
// 2026-11-06 (Joel: "when you mark the last step as reviewed and next you just go back
|
|
623
|
+
// to the beginning"): on the last step it goes to the first step still unreviewed
|
|
624
|
+
// ("& step 2"); only when every step is reviewed does it read "& finish", and the
|
|
625
|
+
// overview it lands on says they all are.
|
|
604
626
|
export const NEXT_TIP = 'Next, without marking (→)'
|
|
605
627
|
export const REVNEXT_TIP = 'Mark this step reviewed and go to the next (Shift+→ or Enter)'
|
|
606
|
-
export const REVFINISH_TIP = 'Mark this step reviewed and finish:
|
|
607
|
-
|
|
608
|
-
|
|
628
|
+
export const REVFINISH_TIP = 'Mark this step reviewed and finish: every step is then reviewed (Shift+→ or Enter)'
|
|
629
|
+
export const revBackTip = (n) => 'Mark this step reviewed and go back to step ' + n + ', not reviewed yet (Shift+→ or Enter)'
|
|
630
|
+
// slides: [{ step }] in deck order (step: an id, or { id, n }), reviewed: the step ids already
|
|
631
|
+
// reviewed -> { show, last, to, back } (to: the slide it goes to; back: the step number it
|
|
632
|
+
// goes back to, when the last step leaves one unreviewed)
|
|
633
|
+
export function revNextState(slides, cur, reviewed = new Set()) {
|
|
609
634
|
const s = slides[cur]
|
|
610
|
-
if (!s || !s.step) return { show: false, last: false, to: cur + 1 }
|
|
635
|
+
if (!s || !s.step) return { show: false, last: false, to: cur + 1, back: null }
|
|
611
636
|
let lastStep = -1
|
|
612
637
|
slides.forEach((x, i) => { if (x.step) lastStep = i })
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
638
|
+
if (cur !== lastStep) return { show: true, last: false, to: cur + 1, back: null }
|
|
639
|
+
const id = (x) => (x && typeof x === 'object' ? x.id : x)
|
|
640
|
+
const left = slides.findIndex((x, i) => i !== cur && x.step && !reviewed.has(id(x.step)))
|
|
641
|
+
if (left < 0) return { show: true, last: true, to: 0, back: null }
|
|
642
|
+
const st = slides[left].step
|
|
643
|
+
return { show: true, last: false, to: left, back: (st && typeof st === 'object' && st.n) || left }
|
|
644
|
+
}
|
|
645
|
+
export function revNextLabel(last, back = null) { return back != null ? ['Mark reviewed & step ' + back, 'Reviewed & step ' + back] : last ? ['Mark reviewed & finish', 'Reviewed & finish'] : ['Mark reviewed & next', 'Reviewed & next'] }
|
|
646
|
+
export const revNextTip = (st) => (st.back != null ? revBackTip(st.back) : st.last ? REVFINISH_TIP : REVNEXT_TIP)
|
|
617
647
|
function revNextHtml(st, { icon }) {
|
|
618
|
-
const [l, sh] = revNextLabel(st.last)
|
|
619
|
-
return '<button type="button" class="btn primary wt-drev" id="wtDrev" data-action="wt-revnext" aria-label="' + l.replace('&', '&') + '" data-tip="' + (st
|
|
648
|
+
const [l, sh] = revNextLabel(st.last, st.back)
|
|
649
|
+
return '<button type="button" class="btn primary wt-drev" id="wtDrev" data-action="wt-revnext" aria-label="' + l.replace('&', '&') + '" data-tip="' + revNextTip(st) + '"' + (st.show ? '' : ' hidden') + '>' + icon('check') + '<span class="wt-dl"><span class="wt-dl-l">' + l.replace('&', '&') + '</span><span class="wt-dl-s">' + sh.replace('&', '&') + '</span></span></button>'
|
|
620
650
|
}
|
|
621
|
-
export function deckNavHtml({ slides, cur }, { esc, icon, reviewed = new Set() }) {
|
|
651
|
+
export function deckNavHtml({ slides, cur }, { esc, icon, reviewed = new Set(), spots = 0, spot = -1 }) {
|
|
622
652
|
const dot = (s, i) => '<button type="button" class="wt-dot' + (i === cur ? ' cur' : '') + (s.step && reviewed.has(s.step.id) ? ' done' : '') + '" data-action="wt-slide" data-slide="' + i + '"' + (s.step ? ' data-step="' + esc(s.step.id) + '"' : '') +
|
|
623
653
|
' aria-label="' + esc((i === 0 ? 'Overview' : s.step ? 'Step ' + s.step.n + ': ' + mdPlain(s.title) : s.title) + (s.step && reviewed.has(s.step.id) ? ', reviewed' : '')) + '" data-tip="' + esc(i === 0 ? 'Overview' : s.step ? 'Step ' + s.step.n + ': ' + mdPlain(s.title) : s.title) + '"' + (i === cur ? ' aria-current="step"' : '') + '>' +
|
|
624
654
|
(i === 0 ? icon('route') : s.step ? '<span class="wt-dot-n">' + s.step.n + '</span>' : icon('layers')) + '<span class="wt-dot-ok" aria-hidden="true">' + icon('check') + '</span></button>'
|
|
@@ -627,7 +657,8 @@ export function deckNavHtml({ slides, cur }, { esc, icon, reviewed = new Set() }
|
|
|
627
657
|
'<span class="wt-dnow" id="wtDnow">' + esc(mdPlain(slides[cur].title)) + '</span>' +
|
|
628
658
|
'<div class="wt-dots" id="wtDots" role="group" aria-label="Go to a slide">' + slides.map(dot).join('') + '</div>' +
|
|
629
659
|
'<span class="wt-dcount" id="wtDcount" aria-live="polite">' + (cur + 1) + ' / ' + slides.length + '</span>' +
|
|
630
|
-
|
|
660
|
+
spotBtnHtml(spots, spot, { icon }) +
|
|
661
|
+
revNextHtml(revNextState(slides.map((x) => ({ step: x.step ? { id: x.step.id, n: x.step.n } : null })), cur, reviewed), { icon }) +
|
|
631
662
|
'<button type="button" class="btn wt-dnext" data-action="wt-dstep" data-dir="1" aria-label="Next slide, without marking" data-tip="' + NEXT_TIP + '"' + (cur === slides.length - 1 ? ' disabled' : '') + '><span class="wt-dl">Next</span>' + icon('chevron-right') + '</button></nav>'
|
|
632
663
|
}
|
|
633
664
|
export function stepPos(steps, ui) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// 2026-11-05 session titles: one helper for the name a session goes by
|
|
2
|
+
// everywhere (session header, full screen's bar, cards, tabs, pickers).
|
|
3
|
+
// Line one says what the session is doing; where it runs (folder@branch) is a
|
|
4
|
+
// muted second line, never glued to the front of the name. Pure and
|
|
5
|
+
// deterministic (no model call): a prompt used as a name is tidied the way a
|
|
6
|
+
// person would write it as a title.
|
|
7
|
+
|
|
8
|
+
export const TITLE_MAX = 60
|
|
9
|
+
|
|
10
|
+
// "cube_cloud@me/some-branch · Please check…": where it runs, glued to the front
|
|
11
|
+
// of a name by whatever named it. The folder and branch have a line of their own.
|
|
12
|
+
const WHERE_GLUED = /^\s*[\w.~-]+@[\w./~+-]+\s*(?:·|•|\||—|–|:|-)\s+/
|
|
13
|
+
// Words in front of a request that a title doesn't need.
|
|
14
|
+
const FILLER = /^(?:(?:please|pls|plz|hey|hi|hello|yo|ok(?:ay)?|so|alright|now)\b[\s,!.:;-]*|(?:can|could|would|will) (?:you|u)(?: please| pls)?\s+|(?:i(?:'d| would) like you to|i (?:want|need) you to)\s+)/i
|
|
15
|
+
// A title doesn't end on a word that leads into the next one.
|
|
16
|
+
const DANGLING = /[\s,;:–—-]+(?:and|or|but|nor|the|a|an|to|of|for|with|without|in|on|at|from|by|into|onto|about|then|so|that|this|these|those|is|are|was|be|as|if|when|while|because|my|your|our|its|&|\+)$/i
|
|
17
|
+
const URL_RE = /\bhttps?:\/\/[^\s<>()]+/gi
|
|
18
|
+
const LONG_PATH = /(^|\s)(?:~|\.{1,2})?(?:\/[\w.@+-]+){3,}\/?/g
|
|
19
|
+
|
|
20
|
+
const repoOf = (s) => (s && (s.repoLabel || s.repoName || (s.repo && s.repo.name))) || ''
|
|
21
|
+
const branchOf = (s) => (s && (s.branch || (s.repo && s.repo.branch))) || ''
|
|
22
|
+
|
|
23
|
+
// Where a session runs, as one short line: "folder@branch" (the folder alone
|
|
24
|
+
// when the branch says the same or there is none). '' when unknown.
|
|
25
|
+
export function whereLine(s, repo = repoOf(s)) {
|
|
26
|
+
const b = branchOf(s)
|
|
27
|
+
if (!repo) return b || ''
|
|
28
|
+
return b && b !== repo ? repo + '@' + b : repo
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A pasted link, as the few words it stands for: a GitHub pull request or issue
|
|
32
|
+
// is "PR 408" / "issue 12", anything else its host.
|
|
33
|
+
function urlWords(u) {
|
|
34
|
+
const m = u.match(/^https?:\/\/(?:www\.)?github\.com\/[^/\s]+\/[^/\s]+\/(pull|issues)\/(\d+)/i)
|
|
35
|
+
if (m) return (m[1].toLowerCase() === 'pull' ? 'PR ' : 'issue ') + m[2]
|
|
36
|
+
try { return new URL(u).host.replace(/^www\./, '') } catch { return '' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Cut at a word boundary: never mid-word, never on a dangling "and"/"the"/"to".
|
|
40
|
+
// Text with no spaces to cut at (CJK, one long token) is cut by character.
|
|
41
|
+
export function cutTitle(text, max = TITLE_MAX) {
|
|
42
|
+
let t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
|
|
43
|
+
let cut = false
|
|
44
|
+
if (t.length > max) {
|
|
45
|
+
const room = t.slice(0, max)
|
|
46
|
+
const sp = room.lastIndexOf(' ')
|
|
47
|
+
t = /\s/.test(t[max]) ? room : sp > 0 ? room.slice(0, sp) : t.slice(0, max - 1)
|
|
48
|
+
cut = true
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < 4; i++) {
|
|
51
|
+
const before = t
|
|
52
|
+
t = t.replace(DANGLING, '').replace(/[\s,;:(\[{–—-]+$/, '')
|
|
53
|
+
if (t === before) break
|
|
54
|
+
cut = true
|
|
55
|
+
}
|
|
56
|
+
if (!t) return ''
|
|
57
|
+
return cut ? t.replace(/[.…]+$/, '') + '…' : t
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A first prompt as a title: its first real line, no greeting or "please",
|
|
61
|
+
// links as what they point at, a capital first letter, cut cleanly.
|
|
62
|
+
// "Please checkout the PR 408 and then…" -> "Check out the PR 408…"
|
|
63
|
+
export function promptTitleText(text, max = TITLE_MAX) {
|
|
64
|
+
let t = String(text == null ? '' : text).split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
65
|
+
t = t.replace(URL_RE, (u) => ' ' + urlWords(u) + ' ').replace(LONG_PATH, (m, pre) => pre + m.trim().split('/').filter(Boolean).pop())
|
|
66
|
+
t = t.replace(/\s+/g, ' ').trim()
|
|
67
|
+
for (let i = 0; i < 4; i++) { const n = t.replace(FILLER, ''); if (n === t || !n.trim()) break; t = n.trim() }
|
|
68
|
+
t = t.replace(/^checkout\s+(?=the\b|pr\b|#|branch\b)/i, 'Check out ')
|
|
69
|
+
t = t.replace(/^[\s,.:;!?–—-]+/, '')
|
|
70
|
+
t = cutTitle(t, max)
|
|
71
|
+
return t && !/^\S+\.\S/.test(t) ? t.charAt(0).toUpperCase() + t.slice(1) : t // a bare host stays as written
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A stored name, tidied for showing: where it runs comes off the front (it has
|
|
75
|
+
// its own line). A name that came from a prompt (or carried a where prefix,
|
|
76
|
+
// which only automatic names do) is tidied like a prompt; a name you or Claude
|
|
77
|
+
// gave it is kept as written, only cut at a word if it is very long.
|
|
78
|
+
export function displayTitle(title, { source = null, max = TITLE_MAX } = {}) {
|
|
79
|
+
let t = String(title == null ? '' : title).replace(/\s+/g, ' ').trim()
|
|
80
|
+
const glued = WHERE_GLUED.test(t) && t.replace(WHERE_GLUED, '').trim()
|
|
81
|
+
if (glued) t = glued
|
|
82
|
+
if (glued || source === 'prompt' || source === 'fallback') return promptTitleText(t, max) || t
|
|
83
|
+
return t.length > 2 * max ? cutTitle(t, 2 * max) : t
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Is this stored name only an automatic stand-in (where it runs + the first
|
|
87
|
+
// prompt's words)? Then a generated name may replace it, as for a prompt name.
|
|
88
|
+
export function isPlaceholderTitle(title) { return WHERE_GLUED.test(String(title || '')) }
|
package/ui/settings-nav.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// unit tests read it too (test/unit/settings-nav.test.mjs).
|
|
4
4
|
export const SETTINGS = [
|
|
5
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
|
-
{ 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 },
|
|
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 versions mcp claude codex search notes', 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 machine.', words: 'syncthing pair pairing join code icloud dropbox folder move backup other macs machines install accept rename retire merge reinstall' },
|
|
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' },
|
package/ui/sglyph.js
CHANGED
|
@@ -109,14 +109,14 @@ export const countsAsWaiting = (s, offline = false) => !!s && !(offline && s.isL
|
|
|
109
109
|
// hidden inside a control that already names the state: aria-hidden, no role
|
|
110
110
|
// closed a closed session's quiet ring is fainter
|
|
111
111
|
// arrive the page-clock time the new-reply dot appeared (it pulses once from then)
|
|
112
|
-
export function glyphHtml(state, { esc = escDefault, tip = '', quiet = false, hidden = false, closed = false, arrive = null, cls = '' } = {}) {
|
|
112
|
+
export function glyphHtml(state, { esc = escDefault, tip = '', quiet = false, hidden = false, closed = false, arrive = null, cls = '', noTip = false } = {}) {
|
|
113
113
|
if (!GLYPHS.includes(state)) state = 'quiet'
|
|
114
114
|
if (state === 'quiet' && !quiet) return ''
|
|
115
115
|
const label = GLYPH_LABEL[state]
|
|
116
116
|
const inner = state === 'waiting' ? '<span class="sg-in" aria-hidden="true">!</span>' : state === 'offline' ? '<span class="sg-in" aria-hidden="true"></span><span class="sg-slash" aria-hidden="true"></span>' : '<span class="sg-in" aria-hidden="true"></span>'
|
|
117
117
|
const c = 'sg sg-' + state + (state === 'quiet' && closed ? ' sg-closed' : '') + (state === 'unseen' && arrive != null ? ' sg-arrive' : '') + (cls ? ' ' + cls : '')
|
|
118
118
|
const a11y = hidden ? ' aria-hidden="true"' : ' role="img" aria-label="' + esc(tip || label) + '"'
|
|
119
|
-
return '<span class="' + c + '" data-sg="' + state + '"' + a11y + ' data-tip="' + esc(tip || label) + '"' + (state === 'unseen' && arrive != null ? ' data-arrive="' + Math.round(Number(arrive) || 0) + '"' : '') + '>' + inner + '</span>'
|
|
119
|
+
return '<span class="' + c + '" data-sg="' + state + '"' + a11y + (noTip ? '' : ' data-tip="' + esc(tip || label) + '"') + (state === 'unseen' && arrive != null ? ' data-arrive="' + Math.round(Number(arrive) || 0) + '"' : '') + '>' + inner + '</span>'
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
// The words for a glyph in a control's own label (", waiting on you").
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Claude Code slash commands the board can send into a session (Live box).
|
|
2
|
+
// Shared by the daemon (src/live-link.js: what it lets through) and the board
|
|
3
|
+
// (ui/slash.js: the "/" suggestions and the /model and /effort pickers).
|
|
4
|
+
//
|
|
5
|
+
// Checked against Claude Code's own command table (2.1.300, the `claude -p`
|
|
6
|
+
// build on npm; /model and /effort run again on 2.1.319): each of these has a non-interactive form (supportsNonInteractive),
|
|
7
|
+
// so in a session Leftoff runs (`claude -p`, stream-json) "/effort high" written
|
|
8
|
+
// as a user message runs as the command, locally, and its output comes back as
|
|
9
|
+
// the turn's result and as a local_command line in the transcript. In a terminal
|
|
10
|
+
// session the mod runs it with $.command.run, the way it already runs /rename,
|
|
11
|
+
// except a command whose terminal form saves your default (saves, below).
|
|
12
|
+
// Bare /model and /effort open a picker in the terminal, so the board asks for
|
|
13
|
+
// the choice itself. /status and the bare pickers need Claude Code's terminal UI
|
|
14
|
+
// and are left out; anything not listed here is refused.
|
|
15
|
+
|
|
16
|
+
export const SLASH_MAX = 500
|
|
17
|
+
|
|
18
|
+
// arg: 'required' | 'optional' | 'none'. choices: what the board's picker offers
|
|
19
|
+
// (Claude Code's own list); strict: only those. tui: false when the terminal's
|
|
20
|
+
// form opens a panel (so the mod path refuses it rather than leave a panel open).
|
|
21
|
+
// saves: the terminal's form also writes it into ~/.claude/settings.json as your
|
|
22
|
+
// default for new sessions (checked on 2.1.319: "Set model to Haiku 5.5 and saved
|
|
23
|
+
// as your default for new sessions"; `claude -p` says "for this session only").
|
|
24
|
+
// Leftoff never writes ~/.claude, so these go only to sessions Leftoff runs.
|
|
25
|
+
export const SLASH_COMMANDS = [
|
|
26
|
+
{ name: 'model', arg: 'required', hint: '<model>', what: 'Switch the model',
|
|
27
|
+
choices: ['default', 'opus', 'sonnet', 'haiku', 'fable', 'best', 'opus[1m]', 'sonnet[1m]', 'opusplan'], saves: true },
|
|
28
|
+
{ name: 'effort', arg: 'required', hint: '<level>', what: 'Set how hard Claude thinks',
|
|
29
|
+
choices: ['low', 'medium', 'high', 'xhigh', 'max', 'auto'], strict: true, saves: true },
|
|
30
|
+
{ name: 'compact', arg: 'optional', hint: '[instructions]', what: 'Summarize the conversation to free up context' },
|
|
31
|
+
{ name: 'clear', arg: 'none', hint: '', what: 'Start over with empty context (the old one stays resumable)' },
|
|
32
|
+
{ name: 'context', arg: 'none', hint: '', what: 'Show how full the context is' },
|
|
33
|
+
{ name: 'usage', aliases: ['cost'], arg: 'none', hint: '', what: 'Show session cost and plan usage', tui: false },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const BY_NAME = new Map()
|
|
37
|
+
for (const c of SLASH_COMMANDS) { BY_NAME.set(c.name, c); for (const a of c.aliases || []) BY_NAME.set(a, c) }
|
|
38
|
+
|
|
39
|
+
export function slashCommand(name) { return BY_NAME.get(String(name || '').toLowerCase()) || null }
|
|
40
|
+
export function isSlashText(text) { return /^\s*\//.test(String(text == null ? '' : text)) }
|
|
41
|
+
export function slashNames() { return SLASH_COMMANDS.map((c) => '/' + c.name) }
|
|
42
|
+
export function allowedText() { return slashNames().join(', ') }
|
|
43
|
+
|
|
44
|
+
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:\-[\]]{0,79}$/
|
|
45
|
+
// eslint-disable-next-line no-control-regex
|
|
46
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f\u2028\u2029]/
|
|
47
|
+
|
|
48
|
+
function bad(message) { const e = new Error(message); e.status = 400; e.code = 'bad_command'; throw e }
|
|
49
|
+
|
|
50
|
+
// "/effort high" -> { name: 'effort', args: 'high', line: '/effort high', cmd }.
|
|
51
|
+
// One command on one line; leading and trailing spaces are fine; the name is
|
|
52
|
+
// the command's own (an alias becomes it: /cost -> /usage). Throws (400,
|
|
53
|
+
// bad_command) with a message that says what is allowed.
|
|
54
|
+
export function parseSlash(text) {
|
|
55
|
+
const raw = String(text == null ? '' : text)
|
|
56
|
+
const t = raw.replace(/^[ \t]+|[ \t]+$/g, '')
|
|
57
|
+
if (!t.startsWith('/')) bad('a command starts with “/”')
|
|
58
|
+
if (/[\r\n]/.test(t.trim())) bad('one command a message, on one line')
|
|
59
|
+
const s = t.trim()
|
|
60
|
+
if (CONTROL_RE.test(s)) bad('the command has a control character in it')
|
|
61
|
+
if (s.length > SLASH_MAX) bad('the command is over ' + SLASH_MAX + ' characters')
|
|
62
|
+
const m = /^\/([A-Za-z][\w-]*)(?:[ \t]+([\s\S]*))?$/.exec(s)
|
|
63
|
+
if (!m) bad('the board can send ' + allowedText())
|
|
64
|
+
const c = slashCommand(m[1])
|
|
65
|
+
if (!c) bad('/' + m[1] + ' can’t be sent from the board. It can send ' + allowedText())
|
|
66
|
+
const args = (m[2] || '').replace(/[ \t]+/g, ' ').trim()
|
|
67
|
+
if (c.arg === 'none' && args) bad('/' + c.name + ' takes nothing after it')
|
|
68
|
+
if (c.arg === 'required' && !args) bad('/' + c.name + ' needs a choice: ' + c.choices.join(', '))
|
|
69
|
+
if (args.startsWith('/')) bad('one command a message')
|
|
70
|
+
if (c.name === 'model' && !MODEL_RE.test(args)) bad('/model needs one model name, like ' + c.choices.slice(1, 4).join(', '))
|
|
71
|
+
if (c.strict && !c.choices.includes(args.toLowerCase())) bad('/' + c.name + ' takes one of ' + c.choices.join(', '))
|
|
72
|
+
const a = c.strict ? args.toLowerCase() : args
|
|
73
|
+
return { name: c.name, args: a, line: '/' + c.name + (a ? ' ' + a : ''), cmd: c }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The "/" suggestions for what's typed so far ("/ef" -> [effort]).
|
|
77
|
+
export function slashMatches(text) {
|
|
78
|
+
const m = /^\s*\/([\w-]*)$/.exec(String(text == null ? '' : text))
|
|
79
|
+
if (!m) return []
|
|
80
|
+
const q = m[1].toLowerCase()
|
|
81
|
+
return SLASH_COMMANDS.filter((c) => c.name.startsWith(q) || (c.aliases || []).some((a) => a.startsWith(q)))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A bare "/model" or "/effort" (nothing after it): the board shows its picker.
|
|
85
|
+
export function needsPicker(text) {
|
|
86
|
+
const m = /^\s*\/([\w-]+)\s*$/.exec(String(text == null ? '' : text))
|
|
87
|
+
const c = m && slashCommand(m[1])
|
|
88
|
+
return c && c.arg === 'required' && c.choices ? c : null
|
|
89
|
+
}
|
package/ui/slash.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The Live composer's "/" menu (slash commands from the board). Pure functions
|
|
2
|
+
// here, shared with the unit tests; app.js wires them to the page. What can be
|
|
3
|
+
// sent, and the words for it, is ui/slash-commands.js (the daemon checks it too).
|
|
4
|
+
//
|
|
5
|
+
// Typing "/" lists the commands ("/ef" narrows to /effort); picking one fills
|
|
6
|
+
// it in. A command that takes a choice (/model, /effort) then lists its
|
|
7
|
+
// choices ("/effort h" narrows to high). Picking never sends: Enter sends once
|
|
8
|
+
// the menu has nothing more to offer (an exact command or choice closes it).
|
|
9
|
+
// In a terminal session /model and /effort show, disabled, with why: Claude
|
|
10
|
+
// Code's terminal saves them as your default for new sessions.
|
|
11
|
+
|
|
12
|
+
import { slashCommand, slashMatches } from './slash-commands.js'
|
|
13
|
+
|
|
14
|
+
const escS = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
15
|
+
|
|
16
|
+
export const SAVES_WHY = 'In a terminal session this also changes your default for new sessions, so the board doesn’t send it. Type it in the terminal.'
|
|
17
|
+
|
|
18
|
+
// Where commands go for this session: 'headless' (Leftoff runs it: every
|
|
19
|
+
// command, for this session only), 'terminal' (the mod runs it: not /model or
|
|
20
|
+
// /effort), or null (Codex, or nothing to run it: no menu).
|
|
21
|
+
export function slashTarget(s) {
|
|
22
|
+
if (!s || s.codingAgent === 'codex' || s.capture === 'codex' || s.endedAt) return null
|
|
23
|
+
if (s.headless) return 'headless'
|
|
24
|
+
if (s.capture === 'mod') return 'terminal'
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// text -> null (no menu) or { kind: 'commands' | 'choices', cmd?, items: [{ value, label, hint, what, off }] }.
|
|
29
|
+
// value: the composer's text after a pick. off: why it can't be picked here (null when it can).
|
|
30
|
+
export function slashMenu(text, { target = 'headless' } = {}) {
|
|
31
|
+
if (!target) return null
|
|
32
|
+
const t = String(text == null ? '' : text)
|
|
33
|
+
const offFor = (c) => (target === 'terminal' && c.saves ? SAVES_WHY : null)
|
|
34
|
+
const m = /^\s*\/([\w-]*)$/.exec(t)
|
|
35
|
+
if (m) {
|
|
36
|
+
const list = slashMatches(t)
|
|
37
|
+
if (!list.length) return null
|
|
38
|
+
// an exact command that takes nothing: the menu has nothing to add, Enter sends it
|
|
39
|
+
const exact = slashCommand(m[1])
|
|
40
|
+
if (exact && exact.arg === 'none' && list.length === 1 && (exact.name === m[1].toLowerCase() || (exact.aliases || []).includes(m[1].toLowerCase()))) return null
|
|
41
|
+
return { kind: 'commands', items: list.map((c) => ({ value: '/' + c.name + (c.arg === 'none' ? '' : ' '), label: '/' + c.name, hint: c.hint || '', what: c.what, off: offFor(c) })) }
|
|
42
|
+
}
|
|
43
|
+
const a = /^\s*\/([\w-]+)[ \t]+(\S*)$/.exec(t)
|
|
44
|
+
const c = a && slashCommand(a[1])
|
|
45
|
+
if (!c || !c.choices) return null
|
|
46
|
+
if (offFor(c)) return { kind: 'choices', cmd: c.name, items: [{ value: t, label: '/' + c.name, hint: '', what: c.what, off: offFor(c) }] }
|
|
47
|
+
const q = a[2].toLowerCase()
|
|
48
|
+
const items = c.choices.filter((x) => x.toLowerCase().startsWith(q))
|
|
49
|
+
if (!items.length || (items.length === 1 && items[0].toLowerCase() === q)) return null // nothing to pick, or already picked
|
|
50
|
+
return { kind: 'choices', cmd: c.name, items: items.map((x) => ({ value: '/' + c.name + ' ' + x, label: x, hint: '', what: '', off: null })) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The highlighted item after a key: ArrowDown / ArrowUp wrap, skipping disabled ones. -> index or -1
|
|
54
|
+
export function slashMove(menu, active, key) {
|
|
55
|
+
const items = (menu && menu.items) || []
|
|
56
|
+
const ok = items.map((x, i) => (x.off ? -1 : i)).filter((i) => i >= 0)
|
|
57
|
+
if (!ok.length) return -1
|
|
58
|
+
const at = ok.indexOf(active)
|
|
59
|
+
if (key === 'ArrowDown') return ok[at < 0 ? 0 : (at + 1) % ok.length]
|
|
60
|
+
if (key === 'ArrowUp') return ok[at < 0 ? ok.length - 1 : (at - 1 + ok.length) % ok.length]
|
|
61
|
+
return at < 0 ? ok[0] : active
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// What a key does with the menu open: 'move' | 'pick' | 'close' | null (the composer's own).
|
|
65
|
+
export function slashKey(ev) {
|
|
66
|
+
if (!ev || ev.isComposing || ev.keyCode === 229) return null
|
|
67
|
+
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') return ev.altKey || ev.metaKey || ev.ctrlKey ? null : 'move'
|
|
68
|
+
if ((ev.key === 'Enter' && !ev.shiftKey && !ev.metaKey && !ev.ctrlKey && !ev.altKey) || (ev.key === 'Tab' && !ev.shiftKey)) return 'pick'
|
|
69
|
+
if (ev.key === 'Escape') return 'close'
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The listbox, above the composer. id: the listbox's id (the textarea's aria-controls);
|
|
74
|
+
// each option's id is id + '-' + index (aria-activedescendant).
|
|
75
|
+
export function slashMenuHtml(menu, { active = -1, id = 'slashMenu', esc = escS } = {}) {
|
|
76
|
+
if (!menu || !menu.items.length) return ''
|
|
77
|
+
const head = menu.kind === 'choices' ? '<div class="slash-h" aria-hidden="true">' + esc('/' + menu.cmd) + '</div>' : ''
|
|
78
|
+
return '<div class="slash-menu" role="listbox" id="' + esc(id) + '" aria-label="' + esc(menu.kind === 'choices' ? 'Choices for /' + menu.cmd : 'Commands') + '">' + head +
|
|
79
|
+
menu.items.map((x, i) => '<div class="slash-opt' + (x.off ? ' off' : '') + '" role="option" id="' + esc(id + '-' + i) + '" data-i="' + i + '" data-value="' + esc(x.value) + '" aria-selected="' + (i === active ? 'true' : 'false') + '"' + (x.off ? ' aria-disabled="true" data-tip="' + esc(x.off) + '"' : '') + '>' +
|
|
80
|
+
'<span class="slash-l">' + esc(x.label) + (x.hint ? ' <span class="slash-hint">' + esc(x.hint) + '</span>' : '') + '</span>' +
|
|
81
|
+
(x.what ? '<span class="slash-w">' + esc(x.off ? 'Not in a terminal session' : x.what) + '</span>' : '') + '</div>').join('') + '</div>'
|
|
82
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// 2026-11-06 subagents working, on the board (src/subagents.js reads them): the session's status says
|
|
2
|
+
// "2 subagents working" instead of looking idle, and Live shows a line with what the newest one is
|
|
3
|
+
// doing ("Bash: pnpm test"). Words and a shape (the spinner), never color alone.
|
|
4
|
+
|
|
5
|
+
export function subCount(s) { const n = s && s.subagents ? Number(s.subagents.n) || 0 : 0; return n > 0 ? n : 0 }
|
|
6
|
+
export function subLabel(sub) {
|
|
7
|
+
const n = sub ? Number(sub.n) || 0 : 0
|
|
8
|
+
return n > 0 ? n + (n === 1 ? ' subagent working' : ' subagents working') : ''
|
|
9
|
+
}
|
|
10
|
+
export function subStep(sub) {
|
|
11
|
+
const l = sub && sub.latest
|
|
12
|
+
if (!l) return ''
|
|
13
|
+
return String(l.tool ? l.tool + (l.detail ? ': ' + l.detail : '') : l.detail || '').replace(/\s+/g, ' ').trim().slice(0, 160)
|
|
14
|
+
}
|
|
15
|
+
const who = (a) => (a && (a.label || (a.type ? a.type + ' agent' : ''))) || 'a subagent'
|
|
16
|
+
export function subTip(sub) {
|
|
17
|
+
const list = (sub && Array.isArray(sub.agents) ? sub.agents : []).map((a) => who(a) + (a && a.bg ? ' (background)' : ''))
|
|
18
|
+
const step = subStep(sub)
|
|
19
|
+
return (list.length ? list.join('\n') : subLabel(sub)) + (step ? '\nNow: ' + step : '')
|
|
20
|
+
}
|
|
21
|
+
// The status pill while subagents work (a session in a turn or with background work)
|
|
22
|
+
export function subPillHtml(s, esc) {
|
|
23
|
+
const sub = s && s.subagents
|
|
24
|
+
const label = subLabel(sub)
|
|
25
|
+
if (!label) return ''
|
|
26
|
+
const tip = subTip(sub)
|
|
27
|
+
return '<span class="pill s-active sub-pill" tabindex="0" data-tip="' + esc(tip) + '" aria-label="' + esc(label + ': ' + tip.replace(/\n/g, ', ')) + '"><span class="sub-spin" aria-hidden="true"></span>' + esc(label) + '</span>'
|
|
28
|
+
}
|
|
29
|
+
// Live's line under the thread
|
|
30
|
+
export function subStripHtml(sub, esc, { now = Date.now() } = {}) {
|
|
31
|
+
const label = subLabel(sub)
|
|
32
|
+
if (!label) return ''
|
|
33
|
+
const step = subStep(sub)
|
|
34
|
+
const at = sub.latest && sub.latest.at ? Number(sub.latest.at) : 0
|
|
35
|
+
const ago = at ? Math.max(0, Math.round((now - at) / 1000)) : null
|
|
36
|
+
const when = ago == null ? '' : ago < 5 ? 'just now' : ago < 90 ? ago + 's ago' : Math.round(ago / 60) + 'm ago'
|
|
37
|
+
return '<div class="lv-subs" role="status" data-tip="' + esc(subTip(sub)) + '"><span class="sub-spin" aria-hidden="true"></span><span class="lv-subs-what"><b>' + esc(label) + '</b>' +
|
|
38
|
+
(step ? '<span class="lv-subs-step">' + esc(step) + '</span>' : '') + '</span>' + (when ? '<span class="faint lv-subs-when">' + esc(when) + '</span>' : '') + '</div>'
|
|
39
|
+
}
|
|
40
|
+
// What Live's refetch compares, beside the thread's own signature
|
|
41
|
+
export function subSig(sub) { return sub && sub.n > 0 ? sub.n + ':' + (sub.latest ? sub.latest.at + (sub.latest.tool || '') + (sub.latest.detail || '') : '') : '' }
|
package/ui/summary-examples.js
CHANGED
|
@@ -40,7 +40,7 @@ export const SUMMARY_EXAMPLES = {
|
|
|
40
40
|
}
|
|
41
41
|
export const LENGTH_CHOICES = [
|
|
42
42
|
{ key: 'short', label: 'Short', approx: '~300 tokens', blurb: 'Doing, Stopped at, Next. Three lines.' },
|
|
43
|
-
{ key: 'informative', label: 'Informative', approx: '~700 tokens', blurb: 'Adds
|
|
43
|
+
{ key: 'informative', label: 'Informative', approx: '~700 tokens', blurb: 'Adds approach notes, open questions, files touched and why, and what didn’t work.', recommended: true },
|
|
44
44
|
{ key: 'long', label: 'Long', approx: '~1500 tokens', blurb: 'Adds a narrative recap and the next 3 steps.' },
|
|
45
45
|
]
|
|
46
46
|
|
package/ui/tabs.js
CHANGED
|
@@ -222,6 +222,13 @@ export function stateMark(v, esc) {
|
|
|
222
222
|
// 0.9.17 the walkthrough's Look-closely count: a small eye and a number, as quiet as the
|
|
223
223
|
// status word (not a badge), only when there's something; the words in the tip.
|
|
224
224
|
const EYE = '<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="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>'
|
|
225
|
+
// 0.9.18 the strip: a closable tab's shape sits in the ×'s place (the × shows on hover or focus),
|
|
226
|
+
// so a busy strip keeps room for titles; the word is in the tab's tip and its label. A pinned tab
|
|
227
|
+
// (no ×) and a touch screen (no hover) keep the shape beside the title. Shapes differ per state.
|
|
228
|
+
export function slotMark(v, esc) {
|
|
229
|
+
const st = stateOf(v)
|
|
230
|
+
return st !== 'quiet' ? glyphHtml(st, { esc, hidden: true, noTip: true, cls: 'tab-sg tab-xsg' }) : ''
|
|
231
|
+
}
|
|
225
232
|
export function lookMark(v, esc) {
|
|
226
233
|
const n = Number(v && v.look) || 0
|
|
227
234
|
if (n <= 0) return ''
|
|
@@ -240,12 +247,12 @@ export function stripHtml(views, activeId, onBoard, esc) {
|
|
|
240
247
|
const tabs = views.map((v) => {
|
|
241
248
|
const on = v.id === activeId
|
|
242
249
|
return '<div class="tab-cell' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + (v.pinned ? ' pinned' : '') + '" data-tab-id="' + esc(v.id) + '">' +
|
|
243
|
-
'<a class="tab tab-s' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '"' + (on ? ' aria-current="true"' : '') + ' data-tip="' + esc(v.title + (v.pinned ? ' · pinned' : '') + (v.preview ? ' · preview: double-click to keep it open' : '')) + '" aria-label="' + esc((v.pinned ? 'Pinned: ' : '') + label(v)) + '" aria-keyshortcuts="Alt+Shift+ArrowLeft Alt+Shift+ArrowRight" draggable="false">' +
|
|
244
|
-
(v.pinned ? PIN_GLYPH : '') + '<span class="tab-t">' + esc(v.title) + '</span>' +
|
|
250
|
+
'<a class="tab tab-s' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '"' + (on ? ' aria-current="true"' : '') + ' data-tip="' + esc(v.title + (SHORT[stateOf(v)] ? ' · ' + SHORT[stateOf(v)] : '') + (v.pinned ? ' · pinned' : '') + (v.preview ? ' · preview: double-click to keep it open' : '')) + '" aria-label="' + esc((v.pinned ? 'Pinned: ' : '') + label(v)) + '" aria-keyshortcuts="Alt+Shift+ArrowLeft Alt+Shift+ArrowRight" draggable="false">' +
|
|
251
|
+
(v.pinned ? PIN_GLYPH : '') + '<span class="tab-t">' + esc(v.title) + '</span>' + '<span class="tab-st' + (v.pinned ? '' : ' tab-st-touch') + '">' + slotMark(v, esc) + '</span>' + lookMark(v, esc) + '</a>' +
|
|
245
252
|
(v.preview
|
|
246
253
|
? '<button type="button" class="tab-x tab-keep" data-tab-keep="' + esc(v.id) + '" data-tip="Keep open" aria-label="' + esc('Keep ' + v.title + ' open') + '">' + PIN + '</button>'
|
|
247
254
|
: '') +
|
|
248
|
-
(v.pinned ? '' : '<button type="button" class="tab-x" data-tab-close="' + esc(v.id) + '" data-tip="Close tab" aria-label="' + esc('Close ' + v.title) + '">' + X + '</button>') + // a pinned tab stays: Unpin is on its right-click
|
|
255
|
+
(v.pinned ? '' : '<button type="button" class="tab-x' + (slotMark(v, esc) ? ' has-st' : '') + '" data-tab-close="' + esc(v.id) + '" data-tip="Close tab" aria-label="' + esc('Close ' + v.title) + '">' + slotMark(v, esc) + X + '</button>') + // a pinned tab stays: Unpin is on its right-click
|
|
249
256
|
'</div>'
|
|
250
257
|
}).join('')
|
|
251
258
|
// 0.9.16: + New session right after the open tabs
|