@leftoff/cli 0.9.7 → 0.9.8-beta.1
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 +7 -0
- package/bin/leftoff.js +344 -326
- package/docs/SECURITY.md +10 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +32 -0
- package/ui/app.js +130 -12
- package/ui/needs.js +1 -1
- package/ui/restore-gate.js +26 -2
package/docs/SECURITY.md
CHANGED
|
@@ -96,6 +96,16 @@ When your hosted board is open on a Mac that runs Leftoff, the page sends the sa
|
|
|
96
96
|
|
|
97
97
|
Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
|
|
98
98
|
|
|
99
|
+
### Live link (0.9.8 beta)
|
|
100
|
+
|
|
101
|
+
Live link is opt-in, per session: only sessions started with `leftoff live` have a channel. It adds:
|
|
102
|
+
|
|
103
|
+
- **The channel** is a stdio MCP server that Claude Code starts. It opens no port. It talks to the daemon on 127.0.0.1 with the board's token, and the daemon accepts its calls (hello, next message, ack, reply, permission) from loopback only. A channel is tied to the session whose process started it, so the daemon only writes into sessions on its own Mac. Other Macs go through the peer link.
|
|
104
|
+
- **Prompts from the board or a phone** reach Claude exactly like typed input, so they follow the same rule as answering a permission: a phone needs *Trust my paired devices*, and they're rate-limited and written to the session's audit trail. Anyone who can send prompts can make Claude act, so treat trusting your paired devices as giving them your keyboard.
|
|
105
|
+
- **Permission relay** never allows anything on its own. A prompt waits for Allow once or Deny. With no answer the terminal dialog stays and decides, and an answer in the terminal wins. Live link answers one call at a time and never adds session-wide rules.
|
|
106
|
+
- **Outside events** need this Mac's event key (`~/.config/checkpoints/secrets/live-events`, mode 600, made by `leftoff live key`, replaced with `--new`). Locally that's a Bearer key. Through the relay only an HMAC signature is accepted (5-minute window, replays refused), so the relay can pass an event along but can't read the key, forge an event or change one. Both the relay and the Mac rate-limit, and bodies are capped at 16 KB. Claude's instructions call an event information, not a command, but it is still text Claude reads: anyone with the key can put words in front of your session. Keep the key in your CI's secrets.
|
|
107
|
+
- **Remote Restore** opens Ghostty tabs, so a phone or another Mac can only ask. The Mac shows a notification and a card, and only that Mac's own browser can confirm. Requests expire after 5 minutes, at most 3 wait at once, and they're rate-limited.
|
|
108
|
+
|
|
99
109
|
### Logging
|
|
100
110
|
|
|
101
111
|
The Worker never logs request or response bodies (it can't read them anyway) and stores only what's listed above, in your Durable Object. `wrangler.toml` turns Workers observability (request logs) off. Cloudflare's edge keeps its usual metadata for any site (IP, hostname, path such as `/_relay/api`, status), under its own policies.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8-beta.1",
|
|
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
|
@@ -547,7 +547,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
547
547
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
548
548
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
549
549
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
550
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints']
|
|
550
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink']
|
|
551
551
|
|
|
552
552
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
553
553
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
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.
|
|
31
|
+
const MOD_VERSION = '0.9.8-beta.1' // 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.
|
|
3
|
+
"version": "0.9.8-beta.1",
|
|
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
|
@@ -2015,3 +2015,35 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2015
2015
|
.wz-cmd { width: 100%; }
|
|
2016
2016
|
.wz-nav .btn { padding: 8px 16px; }
|
|
2017
2017
|
}
|
|
2018
|
+
|
|
2019
|
+
/* 0.9.8 Live link: the Live badge, and the session page's message box with its delivery states */
|
|
2020
|
+
.pill.live { color: var(--green); background: var(--tint-green); }
|
|
2021
|
+
.live-box { margin: 16px 0 18px; border: 1px solid color-mix(in srgb, var(--green) 35%, var(--line)); background: linear-gradient(180deg, var(--tint-green), transparent 70%), var(--card); border-radius: var(--radius); padding: 12px 14px 14px; }
|
|
2022
|
+
.live-box.off { border-color: var(--line); background: var(--card); }
|
|
2023
|
+
.live-box.off p { margin: 4px 0 0; font-size: 13px; line-height: 1.5; }
|
|
2024
|
+
.live-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
|
|
2025
|
+
.live-head h2 { margin: 0; font-size: 15px; display: inline-flex; align-items: center; gap: 8px; }
|
|
2026
|
+
.live-head .faint { font-size: 12.5px; }
|
|
2027
|
+
.live-log { list-style: none; margin: 8px 0 10px; padding: 0; display: flex; flex-direction: column; gap: 8px; max-height: 420px; overflow-y: auto; }
|
|
2028
|
+
.lv { border: 1px solid var(--line); border-radius: 10px; padding: 7px 10px; background: var(--card); max-width: 88%; }
|
|
2029
|
+
.lv-you { align-self: flex-end; background: var(--tint-accent); border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
|
|
2030
|
+
.lv-event { align-self: flex-end; background: var(--tint-yellow); border-color: color-mix(in srgb, var(--yellow) 35%, var(--line)); }
|
|
2031
|
+
.lv-claude { align-self: flex-start; }
|
|
2032
|
+
.lv-who { font-size: 11.5px; color: var(--muted); display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
|
|
2033
|
+
.lv-text { white-space: pre-wrap; word-break: break-word; font-size: 13.5px; color: var(--text); margin-top: 2px; }
|
|
2034
|
+
.lv-state { margin-left: auto; font-weight: 600; }
|
|
2035
|
+
.lv-state.sent { color: var(--muted); }
|
|
2036
|
+
.lv-state.received { color: var(--green); }
|
|
2037
|
+
.lv-state.failed { color: var(--red); }
|
|
2038
|
+
.live-empty { margin: 6px 0 10px; font-size: 13px; }
|
|
2039
|
+
.live-err { color: var(--red); font-size: 13px; }
|
|
2040
|
+
.live-row .live-text { min-height: 44px; }
|
|
2041
|
+
.restore-ask .rs-m { color: var(--muted); font-size: 12.5px; }
|
|
2042
|
+
.rs-note.rs-ask.ok { color: var(--green); }
|
|
2043
|
+
.rs-note.rs-ask.warn { color: var(--red); }
|
|
2044
|
+
@media (pointer: coarse), (max-width: 600px) {
|
|
2045
|
+
.live-row .btn, .restore-ask .btn { min-height: 44px; min-width: 44px; }
|
|
2046
|
+
.need-actions .btn, .deny-form .btn { min-height: 44px; } /* Allow / Deny from the phone */
|
|
2047
|
+
.live-box { padding: 10px; margin-left: -4px; margin-right: -4px; }
|
|
2048
|
+
.lv { max-width: 100%; }
|
|
2049
|
+
}
|
package/ui/app.js
CHANGED
|
@@ -14,7 +14,7 @@ import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
|
14
14
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
15
15
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
16
16
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
17
|
-
import { restoreAccess, hintHtml, restoreWhen } from './restore-gate.js'
|
|
17
|
+
import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
|
|
18
18
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
19
19
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
20
20
|
const $app = document.getElementById('app')
|
|
@@ -112,6 +112,8 @@ function copiedMark(btn) {
|
|
|
112
112
|
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = 'Copy resume command' }, 1500)
|
|
113
113
|
}
|
|
114
114
|
const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
|
|
115
|
+
// 0.9.8 Live link: the session's channel is listening (send it prompts from here)
|
|
116
|
+
const liveBadge = (s) => (s && s.liveLink && s.liveLink.on ? ' <span class="pill live" title="Live link: send this session messages from the board or your phone">Live</span>' : '')
|
|
115
117
|
|
|
116
118
|
const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
117
119
|
|
|
@@ -574,7 +576,7 @@ function card(s) {
|
|
|
574
576
|
'<div class="row1"><div class="title">' + liveDot(s) +
|
|
575
577
|
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' +
|
|
576
578
|
'<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
|
|
577
|
-
'</div>' + pill(s.status) + '</div>' +
|
|
579
|
+
'</div>' + pill(s.status) + liveBadge(s) + '</div>' +
|
|
578
580
|
'<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' ? ' · no prompt yet' : '') + '</span>' +
|
|
579
581
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
580
582
|
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
|
|
@@ -787,7 +789,7 @@ async function viewBoard() {
|
|
|
787
789
|
'<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
|
|
788
790
|
'<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
|
|
789
791
|
html += syncDetails() + staleLine(sessions)
|
|
790
|
-
html += restoreBanner()
|
|
792
|
+
html += restoreAsksBanner() + restoreBanner()
|
|
791
793
|
// Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
|
|
792
794
|
const needs = needsItems(sessions)
|
|
793
795
|
html += needsStrip(needs, needOpts())
|
|
@@ -1039,14 +1041,49 @@ function emptyFold(list) {
|
|
|
1039
1041
|
// (0.9.7: or when the terminal quit),
|
|
1040
1042
|
// with a checkbox each; Restore opens each in a new Ghostty tab, resumed by id.
|
|
1041
1043
|
// Opening tabs runs on the Mac, so (like Go) only its own browser may press it.
|
|
1042
|
-
const restoreUi = { open: false, off: new Set(), result: null }
|
|
1044
|
+
const restoreUi = { open: false, off: new Set(), result: null, ask: null, busy: new Set() }
|
|
1043
1045
|
function restoreDismissKey(r) { return 'lo.restore.dismissed:' + (r.restartAt || '') }
|
|
1044
1046
|
function restorePicked() { const r = state.restore; return r ? r.sessions.filter((s) => !restoreUi.off.has(s.id)) : [] }
|
|
1045
1047
|
// 0.9.7: this Mac's own board, or the hosted board open on it (the direct path)
|
|
1046
1048
|
function restoreGate() {
|
|
1047
1049
|
const lr = window.leftoffRelay, r = state.restore || {}
|
|
1048
1050
|
return restoreAccess({ local: state.viewerLocal !== false, hosted: !!lr, route: lr ? lr.route : null, routeMac: lr ? lr.mac : null, machine: r.machine || null,
|
|
1049
|
-
phone: isPhoneUa(navigator.userAgent, { maxTouchPoints: navigator.maxTouchPoints || 0 }) })
|
|
1051
|
+
phone: isPhoneUa(navigator.userAgent, { maxTouchPoints: navigator.maxTouchPoints || 0 }), canAsk: Array.isArray(r.asks) })
|
|
1052
|
+
}
|
|
1053
|
+
// 0.9.8: Restore asked from a phone or another Mac, waiting on THIS Mac: confirm
|
|
1054
|
+
// or decline here (this Mac's own board, or the hosted board open on it).
|
|
1055
|
+
function restoreAsksBanner() {
|
|
1056
|
+
const r = state.restore
|
|
1057
|
+
const asks = r && Array.isArray(r.asks) ? r.asks : []
|
|
1058
|
+
if (!asks.length) return ''
|
|
1059
|
+
const gate = restoreGate()
|
|
1060
|
+
const here = gate.how === 'local' || gate.how === 'direct'
|
|
1061
|
+
return asks.map((a) => {
|
|
1062
|
+
const what = a.count + ' session' + (a.count === 1 ? '' : 's') + (a.titles && a.titles.length ? ': ' + a.titles.join(', ') + (a.count > a.titles.length ? '…' : '') : '')
|
|
1063
|
+
const busy = restoreUi.busy.has(a.id)
|
|
1064
|
+
return '<div class="banner warn restore-ask" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to restore ' + esc(what) + '</b> on ' + esc(r.machine || 'this Mac') + '. ' +
|
|
1065
|
+
(here ? 'Open them here, each in a new Ghostty tab?' : 'Someone at ' + esc(r.machine || 'that Mac') + ' has to confirm it there.') + ' <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
|
|
1066
|
+
(here ? '<button type="button" class="btn primary" data-action="restore-ask-confirm" data-id="' + esc(a.id) + '"' + (busy ? ' aria-disabled="true"' : '') + '>' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Open ' + a.count + ' tab' + (a.count === 1 ? '' : 's')) + '</button>' +
|
|
1067
|
+
'<button type="button" class="btn" data-action="restore-ask-decline" data-id="' + esc(a.id) + '"' + (busy ? ' aria-disabled="true"' : '') + '>Decline</button>' : '') + '</div>'
|
|
1068
|
+
}).join('')
|
|
1069
|
+
}
|
|
1070
|
+
function restoreAskNote() {
|
|
1071
|
+
const x = askLine(restoreUi.ask, { clock })
|
|
1072
|
+
return x ? '<span class="rs-note rs-ask ' + x.tone + '" id="restoreAskNote" role="status">' + hintHtml(x.text, esc) + '</span>' : ''
|
|
1073
|
+
}
|
|
1074
|
+
// The asker's side: poll the request until it's settled.
|
|
1075
|
+
async function pollRestoreAsk() {
|
|
1076
|
+
const a = restoreUi.ask
|
|
1077
|
+
if (!a || !a.id || !['pending', 'confirming'].includes(a.state)) return
|
|
1078
|
+
try {
|
|
1079
|
+
const r = await api('GET', '/v1/restore/asks/' + encodeURIComponent(a.id) + (a.machine ? '?machine=' + encodeURIComponent(a.machine) : ''))
|
|
1080
|
+
if (restoreUi.ask && restoreUi.ask.id === a.id) {
|
|
1081
|
+
const changed = r.state !== a.state
|
|
1082
|
+
restoreUi.ask = { ...a, ...r, machine: a.machine || r.machine }
|
|
1083
|
+
if (changed) { const el = document.getElementById('restoreBanner'); if (el) el.outerHTML = restoreBanner(); if (r.state === 'confirmed') toast('Restored on ' + (a.machine || 'that Mac')) }
|
|
1084
|
+
}
|
|
1085
|
+
} catch (e) { if (restoreUi.ask && restoreUi.ask.id === a.id && /gone|not_found/.test(String(e.message))) restoreUi.ask = { ...a, state: 'failed', error: e.message } }
|
|
1086
|
+
if (restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)) setTimeout(pollRestoreAsk, 2000)
|
|
1050
1087
|
}
|
|
1051
1088
|
function restoreBanner() {
|
|
1052
1089
|
const r = state.restore
|
|
@@ -1062,17 +1099,18 @@ function restoreBanner() {
|
|
|
1062
1099
|
}
|
|
1063
1100
|
const gate = restoreGate()
|
|
1064
1101
|
const picked = restorePicked().length
|
|
1102
|
+
const asking = restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)
|
|
1065
1103
|
const rows = r.sessions.map((s) => '<label class="rs-row"><input type="checkbox" name="rs" value="' + esc(s.id) + '"' + (restoreUi.off.has(s.id) ? '' : ' checked') + '>' +
|
|
1066
1104
|
'<span class="rs-main"><span class="rs-t">' + esc(s.title || '(untitled)') + '</span><span class="rs-m">' + esc([s.repoLabel, s.branch, s.modelLabel, s.lastAliveAt ? 'last seen ' + ago(s.lastAliveAt) : null, 'cp:' + (s.shortId || s.id.slice(0, 8))].filter(Boolean).join(' · ')) + '</span>' + (s.blocked ? '<span class="rs-m err">' + esc(s.error) + '</span>' : '') + '</span></label>').join('')
|
|
1067
1105
|
return '<div class="banner info restore open" id="restoreBanner" role="region" aria-label="Restore sessions"><div class="rs-head"><span class="grow">' + head + '. Each opens in a new Ghostty tab, in its folder, with <code>claude --resume</code> and its name.</span>' +
|
|
1068
1106
|
'<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
|
|
1069
1107
|
'<div class="rs-list">' + rows + '</div>' +
|
|
1070
1108
|
'<div class="rs-actions">' +
|
|
1071
|
-
'<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked ? '' : ' aria-disabled="true"') +
|
|
1072
|
-
(gate.ok ? '' : ' aria-describedby="restoreHint" title="' + esc(gate.hint.replace(/`/g, '')) + '"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
|
|
1109
|
+
'<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked && !asking ? '' : ' aria-disabled="true"') +
|
|
1110
|
+
(gate.ok && gate.how !== 'ask' ? '' : ' aria-describedby="restoreHint"') + (gate.ok ? '' : ' title="' + esc(gate.hint.replace(/`/g, '')) + '"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
|
|
1073
1111
|
'<button type="button" class="btn" data-action="restore-all">' + (picked === r.sessions.length ? 'Select none' : 'Select all') + '</button>' +
|
|
1074
1112
|
'<button type="button" class="btn" data-action="restore-copy">Copy commands</button>' +
|
|
1075
|
-
(gate.ok ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
|
|
1113
|
+
(restoreUi.ask ? restoreAskNote() : gate.ok && gate.how !== 'ask' ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
|
|
1076
1114
|
'</div>' + (restoreUi.result ? restoreResult() : '') + '</div>'
|
|
1077
1115
|
}
|
|
1078
1116
|
// 0.9.7: the hosted board found (or lost) this Mac's direct path: Restore follows it
|
|
@@ -1158,7 +1196,7 @@ function needOpts(extra = {}) {
|
|
|
1158
1196
|
document.addEventListener('keydown', (ev) => {
|
|
1159
1197
|
if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.altKey || ev.shiftKey) return
|
|
1160
1198
|
const box = ev.target && ev.target.closest && ev.target.closest('[data-submit]')
|
|
1161
|
-
const need = box && box.closest('.need')
|
|
1199
|
+
const need = box && (box.closest('.need') || box.closest('.live-box')) // 0.9.8: the Live box too
|
|
1162
1200
|
if (!need) return
|
|
1163
1201
|
ev.preventDefault()
|
|
1164
1202
|
if (ev.repeat || ev.isComposing) return
|
|
@@ -1234,20 +1272,23 @@ async function viewSession(id) {
|
|
|
1234
1272
|
const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
|
|
1235
1273
|
html += '<div class="pagehead"><div>' + (canRename
|
|
1236
1274
|
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + esc(s.title || 'Untitled session') + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
|
|
1237
|
-
: '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' ' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
|
|
1275
|
+
: '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + liveBadge(s) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' ' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
|
|
1238
1276
|
'<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
|
|
1239
1277
|
html += '<div class="facts">' +
|
|
1240
1278
|
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)) +
|
|
1241
1279
|
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) +
|
|
1242
1280
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
1243
1281
|
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>'
|
|
1282
|
+
if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + await liveSection(s)
|
|
1244
1283
|
html += parkRow(s)
|
|
1245
1284
|
html += spacePicker(s, spv, 'session', s.id)
|
|
1246
1285
|
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
|
|
1247
1286
|
// 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
|
|
1248
1287
|
html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
|
|
1249
1288
|
html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
|
|
1250
|
-
|
|
1289
|
+
// 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
|
|
1290
|
+
if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
|
|
1291
|
+
if (!isLive(s)) html += await liveSection(s)
|
|
1251
1292
|
if (s.statusChanges && s.statusChanges.length) {
|
|
1252
1293
|
html += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" title="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
|
|
1253
1294
|
}
|
|
@@ -1276,6 +1317,58 @@ async function viewSession(id) {
|
|
|
1276
1317
|
$app.innerHTML = html
|
|
1277
1318
|
aroundWire(s.id)
|
|
1278
1319
|
}
|
|
1320
|
+
// ------------------------------------------------------------ Live link (0.9.8 beta)
|
|
1321
|
+
|
|
1322
|
+
const isLive = (s) => !!(s && s.liveLink && s.liveLink.on)
|
|
1323
|
+
function sessionNeedsStrip(s) { return '<section class="needs-strip one" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>' }
|
|
1324
|
+
const LIVE_STATE = { sent: 'Sent, waiting for the session', received: 'Received by the session', failed: 'Didn’t arrive' }
|
|
1325
|
+
function liveEntry(m) {
|
|
1326
|
+
const who = m.from === 'claude' ? 'Claude' : m.from === 'event' ? 'Event' + (m.source ? ' · ' + m.source : '') : 'You' + (m.where && !/^on this machine/.test(m.where) ? ' · ' + m.where : '')
|
|
1327
|
+
const st = m.from === 'claude' ? '' : '<span class="lv-state ' + esc(m.state || '') + '">' + esc(m.state === 'failed' ? (m.errorText || LIVE_STATE.failed) : LIVE_STATE[m.state] || '') + '</span>'
|
|
1328
|
+
return '<li class="lv lv-' + esc(m.from) + '"><div class="lv-who">' + esc(who) + ' <span class="faint">' + esc(ago(m.at)) + '</span>' + st + '</div><div class="lv-text">' + esc(m.text) + '</div></li>'
|
|
1329
|
+
}
|
|
1330
|
+
// The how-to for a session that isn't live (fetched once: the claude line has this Mac's path).
|
|
1331
|
+
async function liveHow() {
|
|
1332
|
+
if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
|
|
1333
|
+
return state.liveHow
|
|
1334
|
+
}
|
|
1335
|
+
async function liveSection(s) {
|
|
1336
|
+
if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
|
|
1337
|
+
if (!isLive(s)) {
|
|
1338
|
+
if (s.endedAt || s.live === 'closed') return ''
|
|
1339
|
+
const how = await liveHow()
|
|
1340
|
+
return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
|
|
1341
|
+
'<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>' +
|
|
1342
|
+
(how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p></section>'
|
|
1343
|
+
}
|
|
1344
|
+
let v = null
|
|
1345
|
+
try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
|
|
1346
|
+
const msgs = (v && v.messages) || []
|
|
1347
|
+
const may = state.mayAnswer !== false
|
|
1348
|
+
const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
|
|
1349
|
+
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>' +
|
|
1350
|
+
(msgs.length ? '<ol class="live-log" aria-live="polite">' + msgs.map(liveEntry).join('') + '</ol>' : '<p class="faint live-empty">Nothing sent yet.</p>') +
|
|
1351
|
+
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
1352
|
+
'<div class="reply-row live-row"><textarea class="reply-text live-text" rows="2" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter') + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
|
|
1353
|
+
'<button type="button" class="btn primary" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '"' + (may ? '' : ' aria-disabled="true"') + '>Send</button></div>' +
|
|
1354
|
+
(may ? '' : '<p class="faint">Sending from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.</p>') + '</section>'
|
|
1355
|
+
}
|
|
1356
|
+
async function liveSendClick(btn) {
|
|
1357
|
+
const box = btn.closest('.live-box')
|
|
1358
|
+
const ta = box && box.querySelector('.live-text')
|
|
1359
|
+
const text = ta ? ta.value.trim() : ''
|
|
1360
|
+
if (!text) { toast('Type a message first'); return }
|
|
1361
|
+
if (btn.getAttribute('aria-disabled') === 'true') return
|
|
1362
|
+
btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Sending…'
|
|
1363
|
+
try {
|
|
1364
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id) + '/live', { text })
|
|
1365
|
+
if (state.drafts) delete state.drafts['live:' + btn.dataset.id]
|
|
1366
|
+
if (ta) ta.value = ''
|
|
1367
|
+
if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
|
|
1368
|
+
setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
|
|
1369
|
+
} catch (e) { toast(e.message, 4200) } finally { render() }
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1279
1372
|
// ------------------------------------------------------------ search inside transcripts (0.9.6)
|
|
1280
1373
|
|
|
1281
1374
|
// Plain text + [start, end] ranges -> escaped HTML with <mark>s (ranges that
|
|
@@ -3726,6 +3819,18 @@ document.addEventListener('click', async (ev) => {
|
|
|
3726
3819
|
} catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
|
|
3727
3820
|
return
|
|
3728
3821
|
}
|
|
3822
|
+
if (action === 'restore-ask-confirm' || action === 'restore-ask-decline') {
|
|
3823
|
+
const id = btn.dataset.id
|
|
3824
|
+
if (!id || restoreUi.busy.has(id)) return
|
|
3825
|
+
restoreUi.busy.add(id); render()
|
|
3826
|
+
try {
|
|
3827
|
+
const r = await api('POST', '/v1/restore/asks/' + encodeURIComponent(id) + '/' + (action === 'restore-ask-confirm' ? 'confirm' : 'decline'), {}, { timeoutMs: 60000 })
|
|
3828
|
+
toast(r.state === 'confirmed' ? 'Restored ' + ((r.result && r.result.opened) || 0) + ' in Ghostty' : r.state === 'declined' ? 'Declined' : 'That request was ' + r.state)
|
|
3829
|
+
} catch (e) { toast(e.message, 5000) }
|
|
3830
|
+
restoreUi.busy.delete(id)
|
|
3831
|
+
try { state.restore = await api('GET', '/v1/restore') } catch {}
|
|
3832
|
+
return render()
|
|
3833
|
+
}
|
|
3729
3834
|
if (action === 'restore-open') { restoreUi.open = true; restoreUi.result = null; return render() }
|
|
3730
3835
|
if (action === 'restore-close') { restoreUi.open = false; return render() }
|
|
3731
3836
|
if (action === 'restore-dismiss') { try { localStorage.setItem(restoreDismissKey(state.restore || {}), '1') } catch {} restoreUi.open = false; return render() }
|
|
@@ -3736,6 +3841,18 @@ document.addEventListener('click', async (ev) => {
|
|
|
3736
3841
|
if (!ids.length) { toast('Pick at least one'); return }
|
|
3737
3842
|
const gate = restoreGate()
|
|
3738
3843
|
if (!gate.ok) { toast(gate.hint.replace(/`/g, ''), 6000); return }
|
|
3844
|
+
// 0.9.8: from a phone or another Mac, ask that Mac; a person there confirms
|
|
3845
|
+
if (gate.how === 'ask') {
|
|
3846
|
+
if (restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)) return
|
|
3847
|
+
const machine = (state.restore && state.restore.machine) || null
|
|
3848
|
+
btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Asking ' + (machine || 'the Mac') + '…'
|
|
3849
|
+
try {
|
|
3850
|
+
const r = await api('POST', '/v1/restore/asks', { machine, ids })
|
|
3851
|
+
restoreUi.ask = { ...r, machine: machine || r.machine }
|
|
3852
|
+
setTimeout(pollRestoreAsk, 2000)
|
|
3853
|
+
} catch (e) { restoreUi.ask = { state: 'error', machine, error: e.message } }
|
|
3854
|
+
return render()
|
|
3855
|
+
}
|
|
3739
3856
|
btn.disabled = true; btn.textContent = 'Opening ' + ids.length + '…'
|
|
3740
3857
|
const r = await api('POST', '/v1/restore', { ids }, { timeoutMs: 60000 })
|
|
3741
3858
|
restoreUi.result = r.how === 'print' || r.failed ? r : null
|
|
@@ -3885,6 +4002,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
3885
4002
|
}
|
|
3886
4003
|
if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
|
|
3887
4004
|
if (action === 'reply-send') return replyClick(btn)
|
|
4005
|
+
if (action === 'live-send') return liveSendClick(btn)
|
|
3888
4006
|
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
3889
4007
|
if (action === 'need-more') return needMoreClick(btn)
|
|
3890
4008
|
if (action === 'need-dismiss') return needDismissClick(btn)
|
|
@@ -4361,7 +4479,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
4361
4479
|
|
|
4362
4480
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
4363
4481
|
let pending = null
|
|
4364
|
-
const busy = () => !!document.querySelector('.doc-viewer: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))
|
|
4482
|
+
const busy = () => !!document.querySelector('.doc-viewer: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)
|
|
4365
4483
|
// Settings isn't redrawn by live updates: it would wipe what you're editing,
|
|
4366
4484
|
// and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
|
|
4367
4485
|
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) }
|
package/ui/needs.js
CHANGED
|
@@ -81,7 +81,7 @@ export function needPanel(n, opts = {}) {
|
|
|
81
81
|
if (n.more) h += '<div class="need-note">+' + n.more + ' more waiting after this one</div>'
|
|
82
82
|
h += '<div class="need-actions">' +
|
|
83
83
|
'<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
|
|
84
|
-
'<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' title="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>' +
|
|
84
|
+
(n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' title="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>') + // 0.9.8: Live link allows one call at a time
|
|
85
85
|
'<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
|
|
86
86
|
'<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
|
|
87
87
|
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny" data-submit-for="deny"' + dis + '>Deny' + kbdHint(opts) + '</button><button type="button" class="btn" data-action="ask-deny-close">Cancel</button></div>'
|
package/ui/restore-gate.js
CHANGED
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
// route 'direct' | 'relay' (leftoffRelay.route), routeMac: the Mac it is on
|
|
12
12
|
// machine the Mac the restore card belongs to
|
|
13
13
|
// phone a phone or tablet (never runs Leftoff)
|
|
14
|
-
//
|
|
15
|
-
|
|
14
|
+
// canAsk 0.9.8: that Mac takes Restore requests from other devices (GET /v1/restore has asks)
|
|
15
|
+
// -> { ok, how: 'local' | 'direct' | 'ask' | null, hint: text with `commands` or null }
|
|
16
|
+
// 'ask': the request goes to that Mac, where a person confirms before any tab opens.
|
|
17
|
+
export function restoreAccess({ local = false, hosted = false, route = null, routeMac = null, machine = null, phone = false, canAsk = false } = {}) {
|
|
16
18
|
if (local && !hosted) return { ok: true, how: 'local', hint: null }
|
|
17
19
|
if (hosted && !phone && route === 'direct' && machine && routeMac === machine) return { ok: true, how: 'direct', hint: null }
|
|
18
20
|
const m = machine || 'the Mac'
|
|
21
|
+
if (canAsk && machine) return { ok: true, how: 'ask', hint: 'Restore asks ' + m + ' first: someone at ' + m + ' confirms before the tabs open.' }
|
|
19
22
|
if (phone) return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it can’t run from a phone yet. On ' + m + ', run `leftoff restore`.' }
|
|
20
23
|
if (hosted) {
|
|
21
24
|
return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + '. Open this board on ' + m + ', or run `leftoff restore` there. Already on ' + m + '? Run `leftoff setup` and turn on https://leftoff.localhost so this page can reach it.' }
|
|
@@ -23,6 +26,27 @@ export function restoreAccess({ local = false, hosted = false, route = null, rou
|
|
|
23
26
|
return { ok: false, how: null, hint: 'Restore opens Ghostty tabs on ' + m + ', so it works from ' + m + '’s own browser. Or run `leftoff restore` there.' }
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
// 0.9.8: what the card says about a Restore it asked another Mac for.
|
|
30
|
+
// ask: { state, machine, count, expiresAt, result, error } -> { text, tone: 'info' | 'ok' | 'warn' }
|
|
31
|
+
export function askLine(ask, { clock = (iso) => iso } = {}) {
|
|
32
|
+
if (!ask) return null
|
|
33
|
+
const m = ask.machine || 'the Mac', n = ask.count || 0
|
|
34
|
+
const tabs = n + ' tab' + (n === 1 ? '' : 's')
|
|
35
|
+
switch (ask.state) {
|
|
36
|
+
case 'pending': return { text: 'Waiting for someone at ' + m + ' to confirm' + (ask.expiresAt ? ' (until ' + clock(ask.expiresAt) + ')' : '') + '…', tone: 'info' }
|
|
37
|
+
case 'confirming': return { text: 'Confirmed on ' + m + ': opening ' + tabs + '…', tone: 'info' }
|
|
38
|
+
case 'confirmed': {
|
|
39
|
+
const r = ask.result || {}
|
|
40
|
+
if (r.how === 'print') return { text: 'Confirmed on ' + m + ', but Ghostty didn’t open there: run `leftoff restore` on ' + m + '.', tone: 'warn' }
|
|
41
|
+
return { text: 'Confirmed on ' + m + ': ' + (r.opened || 0) + ' of ' + tabs + ' opened' + (r.failed ? ', ' + r.failed + ' didn’t' : '') + '.', tone: r.failed ? 'warn' : 'ok' }
|
|
42
|
+
}
|
|
43
|
+
case 'declined': return { text: 'Declined on ' + m + '. Nothing was opened.', tone: 'warn' }
|
|
44
|
+
case 'expired': return { text: 'Nobody confirmed on ' + m + ' in time. Nothing was opened.', tone: 'warn' }
|
|
45
|
+
case 'failed': return { text: 'It didn’t work on ' + m + ': ' + (ask.error || 'an error') + '.', tone: 'warn' }
|
|
46
|
+
default: return { text: ask.error || 'Couldn’t ask ' + m + '.', tone: 'warn' }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
26
50
|
// The hint as HTML: escaped, `commands` in <code>.
|
|
27
51
|
export function hintHtml(hint, esc) {
|
|
28
52
|
return String(hint || '').split('`').map((part, i) => (i % 2 ? '<code>' + esc(part) + '</code>' : esc(part))).join('')
|