@leftoff/cli 0.9.9-beta.5 → 0.9.9-beta.7
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 +8 -2
- package/bin/leftoff.js +316 -308
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +37 -2
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +48 -2
- package/ui/app.js +112 -26
- package/ui/facts.js +34 -0
- package/ui/icons.js +1 -0
- package/ui/live-view.js +17 -0
- package/ui/start.js +52 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.9-beta.
|
|
3
|
+
"version": "0.9.9-beta.7",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Joel"
|
package/mod/hooks/core.js
CHANGED
|
@@ -1509,13 +1509,48 @@ export function fallbackTitle(session) {
|
|
|
1509
1509
|
return repoName(session) + (branch ? '@' + branch : '') + (words ? ' · ' + words : '')
|
|
1510
1510
|
}
|
|
1511
1511
|
|
|
1512
|
-
|
|
1512
|
+
// 0.9.9: the title is in the language the user writes in (English when unsure),
|
|
1513
|
+
// one language, one script: a small local model once named an English session
|
|
1514
|
+
// "Ants README笑话" ("joke" in Chinese).
|
|
1515
|
+
export const TITLE_LANGUAGE = 'Write it in the same language as the user\'s own messages (English if unsure), in one language only: never mix in words or characters from another language.'
|
|
1516
|
+
export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. ' + TITLE_LANGUAGE + ' Reply with the title only, no quotes, no punctuation at the end.'
|
|
1513
1517
|
|
|
1514
1518
|
export function cleanTitle(text) {
|
|
1515
|
-
const t = String(text || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
1519
|
+
const t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
1516
1520
|
return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
|
|
1517
1521
|
}
|
|
1518
1522
|
|
|
1523
|
+
// The writing systems in a text's letters (digits, punctuation and emoji have none).
|
|
1524
|
+
// Chinese and Japanese characters count as one ("han").
|
|
1525
|
+
const SCRIPTS = [
|
|
1526
|
+
['latin', /\p{Script=Latin}/u], ['han', /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Bopomofo}]/u], ['hangul', /\p{Script=Hangul}/u],
|
|
1527
|
+
['cyrillic', /\p{Script=Cyrillic}/u], ['greek', /\p{Script=Greek}/u], ['arabic', /\p{Script=Arabic}/u], ['hebrew', /\p{Script=Hebrew}/u],
|
|
1528
|
+
['thai', /\p{Script=Thai}/u], ['devanagari', /\p{Script=Devanagari}/u],
|
|
1529
|
+
]
|
|
1530
|
+
export function scriptsOf(text) {
|
|
1531
|
+
const out = new Set()
|
|
1532
|
+
for (const ch of String(text || '')) {
|
|
1533
|
+
if (!/\p{L}/u.test(ch)) continue
|
|
1534
|
+
const hit = SCRIPTS.find(([, re]) => re.test(ch))
|
|
1535
|
+
out.add(hit ? hit[0] : 'other')
|
|
1536
|
+
}
|
|
1537
|
+
return out
|
|
1538
|
+
}
|
|
1539
|
+
// Does a generated title keep to the user's writing? Every script in it must
|
|
1540
|
+
// be one the user wrote in; Latin letters are always fine (file names, code,
|
|
1541
|
+
// "README"). A title that brings in another script is dropped for the fallback.
|
|
1542
|
+
export function titleFitsUser(title, userText) {
|
|
1543
|
+
const mine = scriptsOf(userText)
|
|
1544
|
+
for (const sc of scriptsOf(title)) if (sc !== 'latin' && !mine.has(sc)) return false
|
|
1545
|
+
return true
|
|
1546
|
+
}
|
|
1547
|
+
// The line under a title prompt that pins the language: written in Latin
|
|
1548
|
+
// letters only, the title is too (what small models drift from).
|
|
1549
|
+
export function titleLanguageLine(userText) {
|
|
1550
|
+
const mine = scriptsOf(userText)
|
|
1551
|
+
return [...mine].some((sc) => sc !== 'latin') ? 'Title language: the language of the messages above.' : 'Title language: the language of the messages above (English if unsure), Latin letters only.'
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1519
1554
|
// ---------------------------------------------------------------- catch me up (v0.5.0)
|
|
1520
1555
|
|
|
1521
1556
|
// A model reply -> its bullet lines (at most max), or null when fewer than two.
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.9-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.9-beta.7' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.9-beta.
|
|
3
|
+
"version": "0.9.9-beta.7",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -182,6 +182,20 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
182
182
|
.fact { background: var(--card); box-shadow: var(--card-shadow); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; min-width: 0; }
|
|
183
183
|
.fact .k { color: var(--faint); font-size: 11.5px; text-transform: uppercase; letter-spacing: .6px; }
|
|
184
184
|
.fact .v { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
185
|
+
.fact .k, .fact .v { display: block; }
|
|
186
|
+
/* 0.9.9: a card with an id or a path is a button: tap copies the whole value, which is its tip */
|
|
187
|
+
.fact.copyable { position: relative; width: 100%; min-height: 44px; text-align: left; cursor: pointer; font: inherit; color: inherit; outline: none; -webkit-tap-highlight-color: transparent; transition: border-color .12s, background .12s; }
|
|
188
|
+
.fact.copyable .k { display: flex; align-items: center; gap: 6px; }
|
|
189
|
+
.fact-ic { display: inline-grid; margin-left: auto; color: var(--faint); opacity: .55; transition: opacity .12s, color .12s; }
|
|
190
|
+
.fact-ic svg { width: 13px; height: 13px; }
|
|
191
|
+
@media (hover: hover) { .fact.copyable:hover { border-color: var(--line-strong); } .fact.copyable:hover .fact-ic { opacity: 1; color: var(--text2); } }
|
|
192
|
+
.fact.copyable:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
193
|
+
.fact.copyable:focus-visible .fact-ic { opacity: 1; }
|
|
194
|
+
.fact.copyable.copied { border-color: var(--accent); }
|
|
195
|
+
.fact.copyable.copied .fact-ic { opacity: 1; color: var(--accent); }
|
|
196
|
+
.fact.copyable[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 20; padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font: 500 12px/1.35 var(--mono); text-transform: none; letter-spacing: normal; white-space: normal; overflow-wrap: anywhere; pointer-events: none; }
|
|
197
|
+
@media (hover: hover) { .fact.copyable[data-tip]:hover::after { display: block; } }
|
|
198
|
+
.fact.copyable[data-tip]:focus-visible::after, .fact.copyable.copied[data-tip]::after { display: block; }
|
|
185
199
|
.strip { display: flex; gap: 3px; margin: 8px 0 4px; flex-wrap: wrap; }
|
|
186
200
|
.strip span { font-size: 11px; padding: 2px 8px; border-radius: 6px; }
|
|
187
201
|
.timeline { position: relative; margin-left: 8px; padding-left: 22px; border-left: 2px solid var(--line); }
|
|
@@ -2896,8 +2910,8 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
2896
2910
|
the restore banner): the words use the whole width, the buttons a row of
|
|
2897
2911
|
their own below, 44px tall */
|
|
2898
2912
|
@media (max-width: 640px) {
|
|
2899
|
-
.start-ask-banner > .grow, .restore-ask > .grow, .banner.restore:not(.open) > .grow { flex: 1 1 100%; }
|
|
2900
|
-
.start-ask-banner > .btn, .restore-ask > .btn, .banner.restore:not(.open) > .btn:not(.x) { flex: 1 1 0; justify-content: center; min-height: 44px; }
|
|
2913
|
+
.start-ask-banner > .grow, .restore-ask > .grow, .banner.restore:not(.open) > .grow, .pair-ask > .grow { flex: 1 1 100%; }
|
|
2914
|
+
.start-ask-banner > .btn, .restore-ask > .btn, .banner.restore:not(.open) > .btn:not(.x), .pair-ask > .btn { flex: 1 1 0; justify-content: center; min-height: 44px; }
|
|
2901
2915
|
.restore-ask > .rs-m { flex: 1 1 100%; }
|
|
2902
2916
|
}
|
|
2903
2917
|
|
|
@@ -2932,9 +2946,41 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
2932
2946
|
.sb-foot { display: flex; justify-content: flex-end; }
|
|
2933
2947
|
.sb-foot .btn { min-height: 44px; }
|
|
2934
2948
|
.sb-crumb:focus-visible, .sb-cut:focus-visible, .sb-dir:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2949
|
+
/* Go to a folder: type a path you know */
|
|
2950
|
+
.sb-path { display: flex; flex-direction: column; gap: 4px; margin: 0; }
|
|
2951
|
+
.sb-path-l { font-size: 12px; color: var(--muted); }
|
|
2952
|
+
.sb-path-row { display: flex; gap: 8px; min-width: 0; }
|
|
2953
|
+
.sb-path-in { flex: 1 1 auto; min-width: 0; min-height: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--card); color: var(--text); font: inherit; font-family: var(--mono); font-size: 16px; }
|
|
2954
|
+
.sb-path-in:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
2955
|
+
.sb-path-in[aria-invalid="true"] { border-color: var(--red); }
|
|
2956
|
+
.sb-path-row .btn { min-height: 44px; min-width: 44px; justify-content: center; }
|
|
2957
|
+
.sb-path-err { margin: 0; }
|
|
2935
2958
|
.start-custom .start-opt-main { white-space: normal; }
|
|
2936
2959
|
@media (max-width: 640px) {
|
|
2937
2960
|
.start-browse { padding: 10px; margin-left: -4px; margin-right: -4px; }
|
|
2938
2961
|
.sb-list { grid-template-columns: 1fr; max-height: none; overflow: visible; }
|
|
2939
2962
|
.sb-here .sb-use { flex: 1 1 100%; justify-content: center; }
|
|
2940
2963
|
}
|
|
2964
|
+
|
|
2965
|
+
/* 0.9.9 asked at pairing time: may other devices start sessions here? */
|
|
2966
|
+
.pair-ask .grow { min-width: 0; }
|
|
2967
|
+
@media (pointer: coarse) { .pair-ask > .btn { min-height: 44px; } }
|
|
2968
|
+
|
|
2969
|
+
/* 0.9.9 a setting that saves on change says so beside it, then fades */
|
|
2970
|
+
.saved-mark { display: inline-block; margin-left: 10px; font-size: 13px; font-weight: 600; white-space: nowrap; color: var(--green, var(--accent)); transition: opacity .35s ease; }
|
|
2971
|
+
.saved-mark.bad { color: var(--red); font-weight: 400; white-space: normal; }
|
|
2972
|
+
.saved-mark.gone { opacity: 0; }
|
|
2973
|
+
@media (prefers-reduced-motion: reduce) { .saved-mark { transition: none; } }
|
|
2974
|
+
|
|
2975
|
+
/* 0.9.9 settings kept on one Mac only: "🖥️ studio only" beside the label (a styled tip,
|
|
2976
|
+
never title=), and a locked control (a lock, aria-disabled) when you're not at that Mac */
|
|
2977
|
+
.local-pill { position: relative; display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg2); color: var(--text2); font-size: 11.5px; font-weight: 500; line-height: 1.5; white-space: nowrap; vertical-align: 1px; cursor: default; }
|
|
2978
|
+
.local-pill::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); left: 0; width: max-content; max-width: min(240px, 70vw); padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font-size: 12px; line-height: 1.3; font-weight: 500; white-space: normal; pointer-events: none; z-index: 20; }
|
|
2979
|
+
@media (hover: hover) { .local-pill:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
|
|
2980
|
+
.local-pill.show::after { display: block; }
|
|
2981
|
+
@media (prefers-reduced-motion: reduce) { .local-pill::after { animation: none !important; } }
|
|
2982
|
+
.local-pill .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
2983
|
+
.form label.check.locked { color: var(--muted); cursor: not-allowed; }
|
|
2984
|
+
.form label.check.locked input { cursor: not-allowed; }
|
|
2985
|
+
.form label.check .lock { display: inline-flex; color: var(--muted); }
|
|
2986
|
+
.form label.check .lock .ic { width: 14px; height: 14px; }
|
package/ui/app.js
CHANGED
|
@@ -16,9 +16,10 @@ import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
|
|
|
16
16
|
import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
|
|
17
17
|
import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
|
|
18
18
|
import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml } from './composer.js' // 0.9.8 the Live composer
|
|
19
|
-
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget } from './start.js' // 0.9.9 New session
|
|
19
|
+
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath } from './start.js' // 0.9.9 New session
|
|
20
20
|
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
21
21
|
import { wireTips } from './tips.js'
|
|
22
|
+
import { factHtml } from './facts.js' // 0.9.9 the session page's info cards
|
|
22
23
|
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
23
24
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
24
25
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
@@ -27,7 +28,7 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
|
|
|
27
28
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
28
29
|
import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
|
|
29
30
|
import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
|
|
30
|
-
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking } from './live-view.js' // 0.9.8 Live link polish
|
|
31
|
+
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking, liveBoxKind, liveThreadWanted } from './live-view.js' // 0.9.8 Live link polish
|
|
31
32
|
import { statusView, elapsedText } from './live-status.js'
|
|
32
33
|
import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
|
|
33
34
|
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
@@ -841,6 +842,7 @@ async function viewBoard() {
|
|
|
841
842
|
'<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
|
|
842
843
|
'<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
|
|
843
844
|
html += syncDetails() + staleLine(sessions)
|
|
845
|
+
html += startPairAskHtml(state.starts, { esc, local: startViewerLocal(state.starts && state.starts.viewer), busy: startUi.pairBusy }) // 0.9.9: asked once, at pairing time
|
|
844
846
|
html += restoreAsksBanner() + startAsksBanner(state.starts, { esc, icon, local: startViewerLocal(state.starts && state.starts.viewer), clock, busy: startUi.busy }) + restoreBanner()
|
|
845
847
|
// Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
|
|
846
848
|
const needs = needsItems(sessions)
|
|
@@ -1496,10 +1498,10 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
1496
1498
|
html += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false }) // 0.9.9: started from the board: Stop / Resume
|
|
1497
1499
|
if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
|
|
1498
1500
|
html += '<div class="facts">' +
|
|
1499
|
-
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
1500
|
-
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
|
|
1501
|
-
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
1502
|
-
fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
|
1501
|
+
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : ''), '', { copy: (s.repo && s.repo.root) || null, what: 'repo folder' }) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap', { copy: s.worktree.path || null, what: 'worktree folder' }) : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—', '', { copy: s.cwd || null, clip: 34 }) + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
1502
|
+
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id, '', { copy: s.id, what: 'session id' }) +
|
|
1503
|
+
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown', '', { copy: s.live === 'open' && s.liveVia === 'pid' && s.pid != null ? String(s.pid) : null, what: 'process id' }) +
|
|
1504
|
+
fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || ''), '', { copy: s.shortId ? 'cp:' + s.shortId : null, what: 'terminal tab id' }) + '</div>'
|
|
1503
1505
|
if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
|
|
1504
1506
|
html += parkRow(s)
|
|
1505
1507
|
html += spacePicker(s, spv, 'session', s.id)
|
|
@@ -1560,7 +1562,8 @@ async function liveHow() {
|
|
|
1560
1562
|
return state.liveHow
|
|
1561
1563
|
}
|
|
1562
1564
|
// Does this session's page show a Live thread (and so fetch one)?
|
|
1563
|
-
|
|
1565
|
+
// (0.9.9: a session Leftoff started reports as "hooks" but always has its thread: before, its page never fetched it and read "Nothing sent yet")
|
|
1566
|
+
const liveWanted = (s) => liveThreadWanted(s)
|
|
1564
1567
|
// GET the Live thread; what it says is the newest the page knows (0.9.8: the
|
|
1565
1568
|
// time of the last good answer decides "Reconnecting…" on a pending bubble).
|
|
1566
1569
|
async function fetchLive(id, { timeoutMs } = {}) {
|
|
@@ -1572,9 +1575,9 @@ async function fetchLive(id, { timeoutMs } = {}) {
|
|
|
1572
1575
|
} catch (e) { return { messages: [], error: e.message } }
|
|
1573
1576
|
}
|
|
1574
1577
|
function liveBoxHtml(s, v) {
|
|
1575
|
-
|
|
1576
|
-
if (
|
|
1577
|
-
|
|
1578
|
+
const kind = liveBoxKind(s) // 0.9.9: one Leftoff started talks Live whatever reports it
|
|
1579
|
+
if (kind === 'none') return ''
|
|
1580
|
+
if (kind === 'how') {
|
|
1578
1581
|
const how = state.liveHow
|
|
1579
1582
|
return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
|
|
1580
1583
|
'<p class="faint">To send this session messages from here or your phone, and answer its permission prompts, start it with <code>leftoff live</code>' +
|
|
@@ -1587,6 +1590,14 @@ function liveBoxHtml(s, v) {
|
|
|
1587
1590
|
const now = Date.now()
|
|
1588
1591
|
const lastClaude = [...msgs].reverse().find((m) => m.from === 'claude')
|
|
1589
1592
|
if (lastClaude) markLiveSeen(s.id, lastClaude.at)
|
|
1593
|
+
// a session Leftoff started that isn't running now: what was said, read-only (Resume here sends again)
|
|
1594
|
+
if (kind === 'readonly') {
|
|
1595
|
+
if (!msgs.length) return ''
|
|
1596
|
+
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Not running. Resume it to send more.</span></div>' +
|
|
1597
|
+
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-label="Messages with this session">' +
|
|
1598
|
+
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
|
|
1599
|
+
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div></section>'
|
|
1600
|
+
}
|
|
1590
1601
|
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
|
|
1591
1602
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
1592
1603
|
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
|
|
@@ -2229,7 +2240,8 @@ function spacePicker(s, spv, kind, key) {
|
|
|
2229
2240
|
options: [['', own ? 'Default (repo or Mac)' : s.space + ' · ' + (SPACE_WHY[s.spaceSource] || 'default')], ...opts.map((x) => [x, x]), ['__new', 'New space…']] }) +
|
|
2230
2241
|
'<span class="faint">' + (kind === 'agent' ? 'For everything from this agent’s feed.' : 'Just this session; repos and Macs are set in <a href="#/settings/spaces">Settings → Spaces</a>.') + '</span></div>'
|
|
2231
2242
|
}
|
|
2232
|
-
|
|
2243
|
+
// (0.9.9: ui/facts.js; an id or a path copies on tap, its whole value in a data-tip)
|
|
2244
|
+
function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
|
|
2233
2245
|
|
|
2234
2246
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
2235
2247
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
@@ -3027,7 +3039,8 @@ function anywhereSection(r, adv = []) {
|
|
|
3027
3039
|
'<div class="full hint">Your board at <b>' + esc(r.host || 'yourname.leftoff.dev') + '</b> from any browser, end-to-end encrypted. <a href="https://leftoff.dev/how-it-works" target="_blank" rel="noopener">How it works ↗</a></div>'
|
|
3028
3040
|
// No name yet on any Mac in this folder: claim one (0.6.1, an email link, no admin token).
|
|
3029
3041
|
if ((!r.user || su.status === 'pending') && su.status !== 'confirmed') h += signupBlock(r, su, local)
|
|
3030
|
-
h += '<label for="f-anywhere">Use from anywhere</label><label class="check"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') +
|
|
3042
|
+
h += '<label for="f-anywhere">Use from anywhere' + localPillHtml({ machine: r.machine, emoji: r.machine ? mEmoji(r.machine) : '', esc }) + '</label><label class="check' + (local ? '' : ' locked') + '"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') + lockedAttrs(local) + '>' + lockHtml(local, icon) + '<span>On for this Mac (' + esc(r.machine || '') + ')</span></label>' +
|
|
3043
|
+
(local ? '' : '<div class="hint local-hint">Change on ' + esc(r.machine || 'that Mac') + '.</div>')
|
|
3031
3044
|
// The name: up top until there is one, then Advanced (0.6.6).
|
|
3032
3045
|
const nameRow = '<label for="f-anywhere-user">' + (r.user ? 'Your name' : 'Have a name?') + '</label><div class="anyuser"><input id="f-anywhere-user" value="' + esc(r.user || '') + '" placeholder="yourname" spellcheck="false" autocapitalize="none" autocomplete="off" maxlength="32"' + dis + '><span class="faint mono">.' + dom + '</span>' +
|
|
3033
3046
|
(local && r.user ? '<button type="button" class="btn sm" data-action="relay-user">Save</button>' : '') + '</div>' +
|
|
@@ -3391,14 +3404,14 @@ async function viewSettings() {
|
|
|
3391
3404
|
'<label data-row="model">Model</label><div data-row="model" class="models" id="f-models-list" role="radiogroup" aria-label="Model"><div class="nocp">Loading…</div></div>' +
|
|
3392
3405
|
'<input type="hidden" id="f-model" value="' + esc(sm.model || '') + '">' +
|
|
3393
3406
|
'<label for="f-base" data-row="base">Server URL</label><input id="f-base" data-row="base" value="' + esc(sm.baseUrl || '') + '">' +
|
|
3394
|
-
'<label for="f-key" data-row="key">API key</label><div class="keyrow" data-row="key"><input id="f-key" type="password" autocomplete="new-password" spellcheck="false"><span class="keystate" id="f-keystate"></span></div>' +
|
|
3407
|
+
'<label for="f-key" data-row="key">API key' + localPillHtml({ machine: c.machineResolved, emoji: c.machineResolved ? mEmoji(c.machineResolved) : '', esc }) + '</label><div class="keyrow" data-row="key"><input id="f-key" type="password" autocomplete="new-password" spellcheck="false"><span class="keystate" id="f-keystate"></span></div>' +
|
|
3395
3408
|
'<div class="hint" data-row="key" id="f-key-hint"></div>' +
|
|
3396
3409
|
summariesSection(c) +
|
|
3397
3410
|
advanced('models', lengthExampleHtml(c)),
|
|
3398
3411
|
phone: phonePane(relay, remote, unavailable),
|
|
3399
3412
|
security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
|
|
3400
3413
|
'<div class="full hint">Nothing reaches this board from elsewhere until <a href="#/settings/phone">Phone & remote</a> is on.</div>') +
|
|
3401
|
-
startsSettingHtml(starts, { esc, local: startViewerLocal(starts && starts.viewer) }) +
|
|
3414
|
+
startsSettingHtml(starts, { esc, local: startViewerLocal(starts && starts.viewer), emoji: starts ? mEmoji(starts.machine) : '', icon }) +
|
|
3402
3415
|
// a changed machine key needs a click here, so Other machines stays up top then
|
|
3403
3416
|
(peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
|
|
3404
3417
|
advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
|
|
@@ -3602,9 +3615,10 @@ function machinesSection(p) {
|
|
|
3602
3615
|
if (!p || p.off) return ''
|
|
3603
3616
|
const local = p.viewer && p.viewer.local
|
|
3604
3617
|
let h = '<div class="section" id="machines">Other machines</div>' +
|
|
3605
|
-
'<label for="f-peer-allow">Answer and Go</label><label class="check"><input id="f-peer-allow" type="checkbox"' + (p.allowIncoming ? ' checked' : '') + (local
|
|
3618
|
+
'<label for="f-peer-allow">Answer and Go' + localPillHtml({ machine: p.machine, emoji: p.machine ? mEmoji(p.machine) : '', esc }) + '</label><label class="check' + (local ? '' : ' locked') + '"><input id="f-peer-allow" type="checkbox"' + (p.allowIncoming ? ' checked' : '') + lockedAttrs(local) + '>' + lockHtml(local, icon) + '<span>Allow other machines to answer and Go here</span></label>' +
|
|
3606
3619
|
'<div class="hint">Off by default. On, a Mac that shares this folder can answer this Mac’s permission prompts and questions and press Go, from its own board. Each command is signed with that Mac’s key from the folder, encrypted, and expires in a minute. ' +
|
|
3607
|
-
(p.allowIncoming ? 'Listening on port ' + esc(p.listening || '—') + (p.relay ? (p.relay.connected ? ' and connected to the relay' : ' · relay not connected') : '') + '.' : 'While it’s off nothing listens.') + ' This Mac’s key: <code>' + esc(p.fingerprint) + '</code></div>'
|
|
3620
|
+
(p.allowIncoming ? 'Listening on port ' + esc(p.listening || '—') + (p.relay ? (p.relay.connected ? ' and connected to the relay' : ' · relay not connected') : '') + '.' : 'While it’s off nothing listens.') + ' This Mac’s key: <code>' + esc(p.fingerprint) + '</code></div>' +
|
|
3621
|
+
(local ? '' : '<div class="hint local-hint">Change on ' + esc(p.machine || 'that Mac') + '.</div>')
|
|
3608
3622
|
h += '<div class="full">' + protectHtml('machines') + '</div>'
|
|
3609
3623
|
if (!p.peers.length) return h + '<label>Machines</label><div class="hint">No other Mac in this folder yet.</div>'
|
|
3610
3624
|
h += '<label>Machines</label><div class="peers">' + p.peers.map((x) => {
|
|
@@ -4176,23 +4190,71 @@ async function viewNewSession() {
|
|
|
4176
4190
|
const kf = fa && fa.classList && fa.classList.contains('start-text') ? { a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
4177
4191
|
$app.innerHTML = startPageHtml(startUi.data, startUi, { esc, icon, emoji: mEmoji, local: startViewerLocal(startUi.data.viewer), clock, draft: (state.drafts && state.drafts.start) || '', busy: startUi.sending })
|
|
4178
4192
|
fillStartTexts($app, startTexts(startUi.data, startUi)) // folder names and paths: text, never markup
|
|
4193
|
+
{ const pi = $app.querySelector('.sb-path-in'); if (pi && startUi.browse && startUi.browse.typed && pi.value !== startUi.browse.typed) pi.value = startUi.browse.typed } // what you were typing survives a redraw
|
|
4179
4194
|
const ta = $app.querySelector('.start-text')
|
|
4180
4195
|
if (ta) { fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
|
|
4181
4196
|
}
|
|
4182
4197
|
// Other folder…: list one folder on the picked Mac (that Mac's own disk, over the peer link for another Mac)
|
|
4183
|
-
async function startBrowse(machine, p) {
|
|
4198
|
+
async function startBrowse(machine, p, { typed = false } = {}) {
|
|
4184
4199
|
const seq = (startUi.browseSeq = (startUi.browseSeq || 0) + 1)
|
|
4185
|
-
|
|
4200
|
+
const prev = startUi.browse && startUi.browse.machine === machine ? startUi.browse : {}
|
|
4201
|
+
startUi.browse = { ...prev, machine, loading: true, error: null, pathError: null }
|
|
4186
4202
|
if (state.route && state.route.name === 'new') render()
|
|
4187
4203
|
try {
|
|
4188
4204
|
const d = await api('GET', '/v1/start/dirs?machine=' + encodeURIComponent(machine) + (p ? '&path=' + encodeURIComponent(p) : ''), undefined, { timeoutMs: 20000 })
|
|
4189
4205
|
if (seq !== startUi.browseSeq || !startUi.browse) return
|
|
4190
|
-
startUi.browse = { machine, loading: false, error: null, data: d }
|
|
4206
|
+
startUi.browse = { machine, loading: false, error: null, pathError: null, typed: typed ? '' : startUi.browse.typed, data: d }
|
|
4191
4207
|
} catch (e) {
|
|
4192
4208
|
if (seq !== startUi.browseSeq || !startUi.browse) return
|
|
4193
|
-
|
|
4209
|
+
// a typed path that isn't one you can open: said under the box, and the folder you were in stays
|
|
4210
|
+
startUi.browse = typed ? { ...startUi.browse, loading: false, pathError: e.message } : { ...startUi.browse, loading: false, error: e.message }
|
|
4194
4211
|
}
|
|
4195
|
-
if (state.route && state.route.name === 'new') {
|
|
4212
|
+
if (state.route && state.route.name === 'new') {
|
|
4213
|
+
await render() // the page re-reads before it redraws: focus after
|
|
4214
|
+
const pi = typed && startUi.browse.pathError ? $app.querySelector('.sb-path-in') : null
|
|
4215
|
+
const f = pi || (startUi.browseFocus ? $app.querySelector('.sb-crumb[aria-current]') : null)
|
|
4216
|
+
if (f) f.focus({ preventScroll: true })
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
// "Go to a folder": Enter or Go takes the browser there (the Mac checks it like any other step)
|
|
4220
|
+
document.addEventListener('submit', (ev) => {
|
|
4221
|
+
const f = ev.target && ev.target.closest && ev.target.closest('form[data-form="start-browse-path"]')
|
|
4222
|
+
if (!f) return
|
|
4223
|
+
ev.preventDefault()
|
|
4224
|
+
const b = startUi.browse, inp = f.querySelector('.sb-path-in')
|
|
4225
|
+
const want = typedPath(inp ? inp.value : '')
|
|
4226
|
+
if (!b) return
|
|
4227
|
+
if (!want) { startUi.browse = { ...b, pathError: 'Type a folder, like ~/code/web.' }; render().then(() => { const pi = $app.querySelector('.sb-path-in'); if (pi) pi.focus() }); return }
|
|
4228
|
+
startUi.browseFocus = true
|
|
4229
|
+
startBrowse(b.machine, want, { typed: true })
|
|
4230
|
+
})
|
|
4231
|
+
document.addEventListener('input', (ev) => {
|
|
4232
|
+
if (!ev.target || !ev.target.classList || !ev.target.classList.contains('sb-path-in') || !startUi.browse) return
|
|
4233
|
+
startUi.browse.typed = ev.target.value // kept across the board's redraws
|
|
4234
|
+
})
|
|
4235
|
+
// The per-Mac pill sits inside its setting's <label>: a tap on it shows its tip
|
|
4236
|
+
// (touch has no hover) and never flips the setting.
|
|
4237
|
+
document.addEventListener('click', (ev) => {
|
|
4238
|
+
const pill = ev.target && ev.target.closest && ev.target.closest('.local-pill')
|
|
4239
|
+
for (const x of document.querySelectorAll('.local-pill.show')) if (x !== pill) x.classList.remove('show')
|
|
4240
|
+
if (!pill) return
|
|
4241
|
+
ev.preventDefault()
|
|
4242
|
+
pill.classList.toggle('show')
|
|
4243
|
+
}, true)
|
|
4244
|
+
// 0.9.9: a setting that saves the moment you change it (no Save button) says
|
|
4245
|
+
// so beside it: "Saved ✓" for a moment, or what went wrong, inline.
|
|
4246
|
+
const savedTimers = new Map()
|
|
4247
|
+
function markSaved(id, err = null) {
|
|
4248
|
+
const el = document.getElementById(id)
|
|
4249
|
+
const row = el && (el.closest('label.check') || el)
|
|
4250
|
+
if (!row) return
|
|
4251
|
+
let m = row.querySelector('.saved-mark')
|
|
4252
|
+
if (!m) { m = document.createElement('span'); m.className = 'saved-mark'; row.appendChild(m) } // right beside the checkbox's words
|
|
4253
|
+
m.classList.toggle('bad', !!err); m.classList.remove('gone')
|
|
4254
|
+
m.setAttribute('role', err ? 'alert' : 'status')
|
|
4255
|
+
m.textContent = err ? String(err) : 'Saved ✓'
|
|
4256
|
+
clearTimeout(savedTimers.get(id))
|
|
4257
|
+
if (!err) savedTimers.set(id, setTimeout(() => { m.classList.add('gone'); setTimeout(() => { if (m.classList.contains('gone')) m.remove() }, 400) }, 2000))
|
|
4196
4258
|
}
|
|
4197
4259
|
// Claude Code's own words when a session it ran stopped with an error: text, never markup.
|
|
4198
4260
|
const headlessErrors = new Map()
|
|
@@ -5202,6 +5264,15 @@ document.addEventListener('click', async (ev) => {
|
|
|
5202
5264
|
const id = btn.dataset.id || (card && card.dataset.id)
|
|
5203
5265
|
const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
|
|
5204
5266
|
try {
|
|
5267
|
+
// 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
|
|
5268
|
+
if (action === 'copy-fact') {
|
|
5269
|
+
const ok = await copy(btn.dataset.copy)
|
|
5270
|
+
if (ok) {
|
|
5271
|
+
clearTimeout(btn._copied); btn.classList.add('copied'); btn.dataset.tip = 'Copied'
|
|
5272
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.dataset.tip = btn.dataset.copy }, 1500)
|
|
5273
|
+
}
|
|
5274
|
+
return
|
|
5275
|
+
}
|
|
5205
5276
|
if (action === 'copy-cmd') {
|
|
5206
5277
|
const ok = await copy(btn.dataset.cmd)
|
|
5207
5278
|
if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
|
|
@@ -5407,6 +5478,17 @@ document.addEventListener('click', async (ev) => {
|
|
|
5407
5478
|
return
|
|
5408
5479
|
}
|
|
5409
5480
|
if (action === 'start-all-repos') { startUi.allRepos = true; return render() }
|
|
5481
|
+
if (action === 'pair-ask-yes' || action === 'pair-ask-no') {
|
|
5482
|
+
if (startUi.pairBusy) return
|
|
5483
|
+
startUi.pairBusy = true
|
|
5484
|
+
try {
|
|
5485
|
+
const r = await api('POST', '/v1/starts/pair-ask', { yes: action === 'pair-ask-yes' })
|
|
5486
|
+
toast(r.answered === 'yes' ? 'Other devices can start sessions here' + (r.trustTurnedOn ? ' (Trust my paired devices is on too)' : '') : 'OK, they’ll ask here first', 4000)
|
|
5487
|
+
} catch (e) { toast(e.message, 5000) }
|
|
5488
|
+
startUi.pairBusy = false
|
|
5489
|
+
try { state.starts = await api('GET', '/v1/starts') } catch {}
|
|
5490
|
+
return render()
|
|
5491
|
+
}
|
|
5410
5492
|
if (action === 'start-again') { startUi.ask = null; return render() }
|
|
5411
5493
|
if (action === 'start-send') return startSend(btn)
|
|
5412
5494
|
if (action === 'start-ask-confirm' || action === 'start-ask-decline') {
|
|
@@ -6154,7 +6236,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
6154
6236
|
|
|
6155
6237
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
6156
6238
|
let pending = null
|
|
6157
|
-
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.start-box') && document.activeElement.value)
|
|
6239
|
+
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.start-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('sb-path-in') && document.activeElement.value)
|
|
6158
6240
|
// Settings isn't redrawn by live updates: it would wipe what you're editing,
|
|
6159
6241
|
// and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
|
|
6160
6242
|
function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name)) && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
|
|
@@ -6194,13 +6276,17 @@ document.addEventListener('change', async (ev) => {
|
|
|
6194
6276
|
}
|
|
6195
6277
|
if (ev.target.id === 'f-start-allow') { // 0.9.9: this Mac only
|
|
6196
6278
|
const cb = ev.target
|
|
6197
|
-
|
|
6198
|
-
|
|
6279
|
+
let err = null
|
|
6280
|
+
try { const r = await api('POST', '/v1/starts/allow', { allow: cb.checked }); toast(r.allowRemote ? 'Your phone and other Macs can now start sessions here' : 'Sessions started from other devices wait for a yes here') } catch (e) { cb.checked = !cb.checked; err = e.message; toast(e.message, 6000) }
|
|
6281
|
+
await viewSettings()
|
|
6282
|
+
return markSaved('f-start-allow', err)
|
|
6199
6283
|
}
|
|
6200
6284
|
if (ev.target.id === 'f-peer-allow') {
|
|
6201
6285
|
const cb = ev.target
|
|
6202
|
-
|
|
6203
|
-
|
|
6286
|
+
let err = null
|
|
6287
|
+
try { const r = await api('POST', '/v1/peers/allow', { allow: cb.checked }); toast(r.allowIncoming ? 'Other machines may now answer and Go here' : 'Other machines can no longer answer or Go here') } catch (e) { cb.checked = !cb.checked; err = e.message; toast(e.message, 6000) }
|
|
6288
|
+
await viewSettings()
|
|
6289
|
+
return markSaved('f-peer-allow', err)
|
|
6204
6290
|
}
|
|
6205
6291
|
if (ev.target.dataset && ev.target.dataset.list && ev.target.type === 'checkbox') {
|
|
6206
6292
|
const cb = ev.target, done = cb.checked, li = cb.closest('li')
|
package/ui/facts.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// 0.9.9 the session page's info cards (Machine, Repo, Directory, Session id…).
|
|
2
|
+
// Pure (the unit tests read it). A card whose value is an id or a path is a
|
|
3
|
+
// button: tap or click copies the whole value (the toast says it), the whole
|
|
4
|
+
// value is its data-tip, and it is a 44px target. A long path shows its start
|
|
5
|
+
// and its end, the part that tells worktrees apart.
|
|
6
|
+
import { icon } from './icons.js'
|
|
7
|
+
import { midClip } from './live-view.js'
|
|
8
|
+
|
|
9
|
+
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
10
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
11
|
+
|
|
12
|
+
// factHtml('Turns', '3') or factHtml('Directory', cwd, { copy: cwd, what: 'directory', clip: 34 })
|
|
13
|
+
// copy: the full value to copy (a card without one is plain); what: its name in "Copy <what>"
|
|
14
|
+
// shown: what the card shows when it isn't the copied value itself (the worktree's name for its path)
|
|
15
|
+
// A long path: its start, then its last folder whole ("/Users/you…/add-a-joke-0994").
|
|
16
|
+
export function clipPath(p, n = 34) {
|
|
17
|
+
const t = String(p == null ? '' : p)
|
|
18
|
+
if (t.length <= n) return t
|
|
19
|
+
const i = t.replace(/\/+$/, '').lastIndexOf('/')
|
|
20
|
+
const last = i >= 0 ? t.slice(i + 1) : ''
|
|
21
|
+
if (!last || last.length > n - 8) return midClip(t, n)
|
|
22
|
+
return t.slice(0, n - last.length - 2) + '…/' + last
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function factHtml(k, v, { cls = '', copy = null, what = null, shown = null, clip = 0 } = {}) {
|
|
26
|
+
const value = String(v == null ? '' : v)
|
|
27
|
+
const view = shown != null ? String(shown) : clip ? (value.includes('/') ? clipPath(value, clip) : midClip(value, clip)) : value
|
|
28
|
+
if (copy == null || copy === '') {
|
|
29
|
+
return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v">' + esc(view) + '</div></div>'
|
|
30
|
+
}
|
|
31
|
+
const full = String(copy)
|
|
32
|
+
return '<button type="button" class="fact copyable' + (cls ? ' ' + cls : '') + '" data-action="copy-fact" data-copy="' + esc(full) + '" data-tip="' + esc(full) + '" aria-label="' + esc('Copy ' + (what || k.toLowerCase()) + ': ' + full) + '">' +
|
|
33
|
+
'<span class="k">' + esc(k) + '<span class="fact-ic" aria-hidden="true">' + icon('copy') + '</span></span><span class="v">' + esc(view) + '</span></button>'
|
|
34
|
+
}
|
package/ui/icons.js
CHANGED
|
@@ -17,6 +17,7 @@ export const ICONS = {
|
|
|
17
17
|
"qr-code": "<rect width=\"5\" height=\"5\" x=\"3\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"16\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"3\" y=\"16\" rx=\"1\"/> <path d=\"M21 16h-3a2 2 0 0 0-2 2v3\"/> <path d=\"M21 21v.01\"/> <path d=\"M12 7v3a2 2 0 0 1-2 2H7\"/> <path d=\"M3 12h.01\"/> <path d=\"M12 3h.01\"/> <path d=\"M12 16v.01\"/> <path d=\"M16 12h1\"/> <path d=\"M21 12v.01\"/> <path d=\"M12 21v-1\"/>",
|
|
18
18
|
"refresh-cw": "<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/> <path d=\"M21 3v5h-5\"/> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/> <path d=\"M8 16H3v5\"/>",
|
|
19
19
|
"check": "<path d=\"M20 6 9 17l-5-5\"/>",
|
|
20
|
+
"lock": "<rect width=\"18\" height=\"11\" x=\"3\" y=\"11\" rx=\"2\" ry=\"2\"/> <path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/>",
|
|
20
21
|
"folder-sync": "<path d=\"M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v.5\"/> <path d=\"M12 10v4h4\"/> <path d=\"m12 14 1.535-1.605a5 5 0 0 1 8 1.5\"/> <path d=\"M22 22v-4h-4\"/> <path d=\"m22 18-1.535 1.605a5 5 0 0 1-8-1.5\"/>",
|
|
21
22
|
"bell": "<path d=\"M10.268 21a2 2 0 0 0 3.464 0\"/> <path d=\"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326\"/>",
|
|
22
23
|
"x": "<path d=\"M18 6 6 18\"/> <path d=\"m6 6 12 12\"/>",
|
package/ui/live-view.js
CHANGED
|
@@ -9,6 +9,23 @@ import { parseAttachedLines } from './attach.js'
|
|
|
9
9
|
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t }
|
|
10
10
|
export const isLiveSession = (s) => !!(s && s.liveLink && s.liveLink.on && !s.endedAt && s.live !== 'closed')
|
|
11
11
|
|
|
12
|
+
// 0.9.9: which Live box a session's page shows, and so whether it fetches the
|
|
13
|
+
// thread. A session Leftoff started (headless) always has one, whatever its
|
|
14
|
+
// capture says ("hooks": its card came from the daemon before Claude's first
|
|
15
|
+
// hook): its first message and the reply are in it, while it runs and after
|
|
16
|
+
// it stops. Another hooks-only session has no Live at all; any other one has
|
|
17
|
+
// a thread while it is live-linked and the how-to otherwise.
|
|
18
|
+
// -> 'none' | 'thread' | 'readonly' (a stopped headless one: no composer) | 'how'
|
|
19
|
+
export function liveBoxKind(s) {
|
|
20
|
+
if (!s || s.agent || s.codingAgent === 'codex') return 'none'
|
|
21
|
+
if (s.capture === 'hooks' && !s.headless) return 'none'
|
|
22
|
+
if (s.liveLink && s.liveLink.on) return 'thread'
|
|
23
|
+
if (s.headless) return 'readonly'
|
|
24
|
+
if (s.endedAt || s.live === 'closed') return 'none'
|
|
25
|
+
return 'how'
|
|
26
|
+
}
|
|
27
|
+
export const liveThreadWanted = (s) => { const k = liveBoxKind(s); return k === 'thread' || k === 'readonly' }
|
|
28
|
+
|
|
12
29
|
// The name a session goes by. A live session is never "Untitled": until it has
|
|
13
30
|
// a title (its first prompt, Claude Code's /rename, a generated one) it goes by
|
|
14
31
|
// its repo and branch.
|